diff --git a/.coveragerc b/.coveragerc index 07d84523780..dfbbb232efc 100644 --- a/.coveragerc +++ b/.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 diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index c570b548360..8772a136eb3 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,35 +1,45 @@ -Make sure you are running the latest version of Home Assistant before reporting an issue. + -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:** + -**Python release (`python3 --version`):** +**Last working Home Assistant release (if known):** +**Operating environment (Hass.io/Docker/Windows/etc.):** + + **Component/platform:** + **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:** diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md new file mode 100644 index 00000000000..2c418c6f63e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -0,0 +1,50 @@ +--- +name: Bug report +about: Create a report to help us improve + +--- + + + +**Home Assistant release with the issue:** + + + +**Last working Home Assistant release (if known):** + + +**Operating environment (Hass.io/Docker/Windows/etc.):** + + +**Component/platform:** + + + +**Description of problem:** + + + +**Problem-relevant `configuration.yaml` entries and (fill out even if it seems unimportant):** +```yaml + +``` + +**Traceback (if applicable):** +``` + +``` + +**Additional information:** diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9a8e6812cf3..c2f65f9a8be 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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: diff --git a/.gitignore b/.gitignore index b3774b06bc8..bf49a1b61c1 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ Icon *.iml # pytest +.pytest_cache .cache # GITHUB Proposed Python stuff: diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/.travis.yml b/.travis.yml index fce86348817..b089d3f89be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index fedab8f6ae4..0da8353e5aa 100644 --- a/CODEOWNERS +++ b/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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9c0c21d0d7..9ad922d7045 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/Dockerfile b/Dockerfile index 5081b4ba721..75d9e9eb716 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ LABEL maintainer="Paulus Schoutsen " #ENV INSTALL_LIBCEC no #ENV INSTALL_PHANTOMJS no #ENV INSTALL_SSOCR no +#ENV INSTALL_IPERF3 no VOLUME /config diff --git a/docs/swagger.yaml b/docs/swagger.yaml deleted file mode 100644 index 488d6bddd46..00000000000 --- a/docs/swagger.yaml +++ /dev/null @@ -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 diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index aa966027922..7d3d2d2af88 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -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 diff --git a/homeassistant/auth.py b/homeassistant/auth.py new file mode 100644 index 00000000000..5e434b74ca8 --- /dev/null +++ b/homeassistant/auth.py @@ -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 diff --git a/homeassistant/auth_providers/__init__.py b/homeassistant/auth_providers/__init__.py new file mode 100644 index 00000000000..4705e7580ca --- /dev/null +++ b/homeassistant/auth_providers/__init__.py @@ -0,0 +1 @@ +"""Auth providers for Home Assistant.""" diff --git a/homeassistant/auth_providers/homeassistant.py b/homeassistant/auth_providers/homeassistant.py new file mode 100644 index 00000000000..c2db193ce1a --- /dev/null +++ b/homeassistant/auth_providers/homeassistant.py @@ -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, + ) diff --git a/homeassistant/auth_providers/insecure_example.py b/homeassistant/auth_providers/insecure_example.py new file mode 100644 index 00000000000..a8e8cd0cb0e --- /dev/null +++ b/homeassistant/auth_providers/insecure_example.py @@ -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, + ) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 34eab679581..a405362d368 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -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], - 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) \ +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_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, - hass: core.HomeAssistant, - verbose: bool = False, - skip_pip: bool = True, - log_rotate_days: Any = None, - log_file: Any = None): +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_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,46 +211,57 @@ 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' + 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, + reset=True, + log_colors={ + 'DEBUG': 'cyan', + 'INFO': 'green', + 'WARNING': 'yellow', + 'ERROR': 'red', + 'CRITICAL': 'red', + } + )) + 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) - try: - from colorlog import ColoredFormatter - logging.getLogger().handlers[0].setFormatter(ColoredFormatter( - colorfmt, - datefmt=datefmt, - reset=True, - log_colors={ - 'DEBUG': 'cyan', - 'INFO': 'green', - 'WARNING': 'yellow', - 'ERROR': 'red', - 'CRITICAL': 'red', - } - )) - except ImportError: - pass - # 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, - loop: asyncio.AbstractEventLoop) -> 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 diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index fde21a265b0..6d5feb87dc2 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -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)) diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py index 49df9f2cefa..626022e362a 100644 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ b/homeassistant/components/alarm_control_panel/alarmdecoder.py @@ -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): diff --git a/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant/components/alarm_control_panel/alarmdotcom.py index 0e96e6448ff..87e85f09da0 100644 --- a/homeassistant/components/alarm_control_panel/alarmdotcom.py +++ b/homeassistant/components/alarm_control_panel/alarmdotcom.py @@ -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.""" diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py index d48a107f33d..9a65fdaff06 100644 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ b/homeassistant/components/alarm_control_panel/concord232.py @@ -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): diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index 845eb81bbe0..f0db378ec15 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -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 } diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py index e5003f1ba1d..25224484c79 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -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): diff --git a/homeassistant/components/alarm_control_panel/ifttt.py b/homeassistant/components/alarm_control_panel/ifttt.py new file mode 100644 index 00000000000..209c5367c92 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/ifttt.py @@ -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 diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 5beb5261607..2f2f89b9dfc 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -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.""" diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index 4b08ad67292..895f5edd5da 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -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.""" diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 1422136c405..8a0dfefdc70 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -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): diff --git a/homeassistant/components/alarm_control_panel/nx584.py b/homeassistant/components/alarm_control_panel/nx584.py index ceb79c1dc7b..ca6f1a44a6f 100644 --- a/homeassistant/components/alarm_control_panel/nx584.py +++ b/homeassistant/components/alarm_control_panel/nx584.py @@ -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): diff --git a/homeassistant/components/alarm_control_panel/satel_integra.py b/homeassistant/components/alarm_control_panel/satel_integra.py index 964047f91e9..4ac3a93fff4 100644 --- a/homeassistant/components/alarm_control_panel/satel_integra.py +++ b/homeassistant/components/alarm_control_panel/satel_integra.py @@ -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): diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index 72784c8178c..391de2033c7 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -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' diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/alarm_control_panel/simplisafe.py index 3b991c5b236..b4906acba3c 100644 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ b/homeassistant/components/alarm_control_panel/simplisafe.py @@ -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): diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 5c1323989d4..674eac97f8c 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -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__) diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py index 74d63b1fb9c..59bfe15fa9b 100644 --- a/homeassistant/components/alarm_control_panel/verisure.py +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -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): diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 0d325534266..c5c68f1af40 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -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) + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_RGB_COLOR: rgb, + }, 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 diff --git a/homeassistant/components/amcrest.py b/homeassistant/components/amcrest.py index b91f1fae565..d0e470e3f8e 100644 --- a/homeassistant/components/amcrest.py +++ b/homeassistant/components/amcrest.py @@ -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: - camera = AmcrestCamera(device.get(CONF_HOST), - device.get(CONF_PORT), - device.get(CONF_USERNAME), - device.get(CONF_PASSWORD)).camera try: + camera = AmcrestCamera(device.get(CONF_HOST), + device.get(CONF_PORT), + device.get(CONF_USERNAME), + device.get(CONF_PASSWORD)).camera + # 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: {}
' @@ -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 diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index d272ebcb1c0..ae89e2fc3b6 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -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()] diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index a9bd5c9c8bc..68445092db7 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -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__) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py new file mode 100644 index 00000000000..0f7295a41e0 --- /dev/null +++ b/homeassistant/components/auth/__init__.py @@ -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 diff --git a/homeassistant/components/auth/client.py b/homeassistant/components/auth/client.py new file mode 100644 index 00000000000..122c3032188 --- /dev/null +++ b/homeassistant/components/auth/client.py @@ -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 diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 8c490754f40..2a7a3887b34 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -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, }) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index ad475be76ca..d72211d5ad1 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -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.""" diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py index f3dbc912ade..72110eb50c9 100644 --- a/homeassistant/components/binary_sensor/bayesian.py +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -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) diff --git a/homeassistant/components/binary_sensor/bloomsky.py b/homeassistant/components/binary_sensor/bloomsky.py index 53f148fe97f..3080cc65532 100644 --- a/homeassistant/components/binary_sensor/bloomsky.py +++ b/homeassistant/components/binary_sensor/bloomsky.py @@ -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) diff --git a/homeassistant/components/binary_sensor/bmw_connected_drive.py b/homeassistant/components/binary_sensor/bmw_connected_drive.py new file mode 100644 index 00000000000..e214610f46d --- /dev/null +++ b/homeassistant/components/binary_sensor/bmw_connected_drive.py @@ -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) diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py index 1effcf1800a..6f59da0755a 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -6,28 +6,39 @@ 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 + entities = [] + 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) - from pydeconz.sensor import DECONZ_BINARY_SENSOR - sensors = hass.data[DATA_DECONZ].sensors - entities = [] + hass.data[DATA_DECONZ_UNSUB].append( + async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) - for key in sorted(sensors.keys(), key=int): - sensor = sensors[key] - if sensor and sensor.type in DECONZ_BINARY_SENSOR: - entities.append(DeconzBinarySensor(sensor)) - async_add_devices(entities, True) + async_add_sensor(hass.data[DATA_DECONZ].sensors.values()) class DeconzBinarySensor(BinarySensorDevice): @@ -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 diff --git a/homeassistant/components/binary_sensor/egardia.py b/homeassistant/components/binary_sensor/egardia.py index ab88de9d3c9..76d90e78376 100644 --- a/homeassistant/components/binary_sensor/egardia.py +++ b/homeassistant/components/binary_sensor/egardia.py @@ -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'} diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index 0aadcc247ea..f358f814dc5 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -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 diff --git a/homeassistant/components/binary_sensor/hive.py b/homeassistant/components/binary_sensor/hive.py index 2d4cbd8d070..46dd1b193e8 100644 --- a/homeassistant/components/binary_sensor/hive.py +++ b/homeassistant/components/binary_sensor/hive.py @@ -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) diff --git a/homeassistant/components/binary_sensor/homematicip_cloud.py b/homeassistant/components/binary_sensor/homematicip_cloud.py new file mode 100644 index 00000000000..40ffe498402 --- /dev/null +++ b/homeassistant/components/binary_sensor/homematicip_cloud.py @@ -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 diff --git a/homeassistant/components/binary_sensor/hydrawise.py b/homeassistant/components/binary_sensor/hydrawise.py new file mode 100644 index 00000000000..a3e0ebd782d --- /dev/null +++ b/homeassistant/components/binary_sensor/hydrawise.py @@ -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')] diff --git a/homeassistant/components/binary_sensor/insteon_plm.py b/homeassistant/components/binary_sensor/insteon_plm.py index 09c4b5c8ea7..9cb87b31749 100644 --- a/homeassistant/components/binary_sensor/insteon_plm.py +++ b/homeassistant/components/binary_sensor/insteon_plm.py @@ -17,24 +17,25 @@ _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) - _LOGGER.debug('Adding device %s entity %s to Binary Sensor platform', - device.address.hex, device.states[state_key].name) + new_entity = InsteonPLMBinarySensor(device, state_key) - new_entity = InsteonPLMBinarySensor(device, state_key) - - async_add_devices([new_entity]) + async_add_devices([new_entity]) class InsteonPLMBinarySensor(InsteonPLMEntity, BinarySensorDevice): @@ -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) diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index fb86244acf3..09f1739cba7 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -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,9 +158,13 @@ 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 - # of the sensor until we receive our first ON event. - self._computed_state = None + # 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 def _negative_node_control_handler(self, event: object) -> None: """Handle an "On" control event from the "negative" node.""" @@ -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: diff --git a/homeassistant/components/binary_sensor/konnected.py b/homeassistant/components/binary_sensor/konnected.py new file mode 100644 index 00000000000..9a16ca5e1ab --- /dev/null +++ b/homeassistant/components/binary_sensor/konnected.py @@ -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() diff --git a/homeassistant/components/binary_sensor/maxcube.py b/homeassistant/components/binary_sensor/maxcube.py index 1043004243a..c131de5420a 100644 --- a/homeassistant/components/binary_sensor/maxcube.py +++ b/homeassistant/components/binary_sensor/maxcube.py @@ -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) - 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)) + # Only add Window Shutters + if cube.is_windowshutter(device): + 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 diff --git a/homeassistant/components/binary_sensor/mercedesme.py b/homeassistant/components/binary_sensor/mercedesme.py deleted file mode 100644 index fcf2d7122e2..00000000000 --- a/homeassistant/components/binary_sensor/mercedesme.py +++ /dev/null @@ -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) diff --git a/homeassistant/components/binary_sensor/mychevy.py b/homeassistant/components/binary_sensor/mychevy.py index a89395ed86f..905e60c34d9 100644 --- a/homeassistant/components/binary_sensor/mychevy.py +++ b/homeassistant/components/binary_sensor/mychevy.py @@ -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 diff --git a/homeassistant/components/binary_sensor/mysensors.py b/homeassistant/components/binary_sensor/mysensors.py index 19fa02f63df..21443021193 100644 --- a/homeassistant/components/binary_sensor/mysensors.py +++ b/homeassistant/components/binary_sensor/mysensors.py @@ -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 diff --git a/homeassistant/components/binary_sensor/nest.py b/homeassistant/components/binary_sensor/nest.py index 4089f3a2eaf..882ff142e8c 100644 --- a/homeassistant/components/binary_sensor/nest.py +++ b/homeassistant/components/binary_sensor/nest.py @@ -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.""" diff --git a/homeassistant/components/binary_sensor/netatmo.py b/homeassistant/components/binary_sensor/netatmo.py index 7997e4e60db..fd0e30ccebc 100644 --- a/homeassistant/components/binary_sensor/netatmo.py +++ b/homeassistant/components/binary_sensor/netatmo.py @@ -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: diff --git a/homeassistant/components/binary_sensor/qwikswitch.py b/homeassistant/components/binary_sensor/qwikswitch.py new file mode 100644 index 00000000000..067021b0c7a --- /dev/null +++ b/homeassistant/components/binary_sensor/qwikswitch.py @@ -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 diff --git a/homeassistant/components/binary_sensor/rainmachine.py b/homeassistant/components/binary_sensor/rainmachine.py new file mode 100644 index 00000000000..601a73298af --- /dev/null +++ b/homeassistant/components/binary_sensor/rainmachine.py @@ -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'] diff --git a/homeassistant/components/binary_sensor/random.py b/homeassistant/components/binary_sensor/random.py index 162d0480389..ab6c1e5d479 100644 --- a/homeassistant/components/binary_sensor/random.py +++ b/homeassistant/components/binary_sensor/random.py @@ -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)) diff --git a/homeassistant/components/binary_sensor/rfxtrx.py b/homeassistant/components/binary_sensor/rfxtrx.py index 8c026131fd3..6ac604a4f1e 100644 --- a/homeassistant/components/binary_sensor/rfxtrx.py +++ b/homeassistant/components/binary_sensor/rfxtrx.py @@ -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), diff --git a/homeassistant/components/binary_sensor/tapsaff.py b/homeassistant/components/binary_sensor/tapsaff.py index 09d28b96f72..c0f6ca3f112 100644 --- a/homeassistant/components/binary_sensor/tapsaff.py +++ b/homeassistant/components/binary_sensor/tapsaff.py @@ -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__) diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index 031e0aa42e5..5405a6a77ba 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -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__) diff --git a/homeassistant/components/binary_sensor/wemo.py b/homeassistant/components/binary_sensor/wemo.py index cc1f602d871..30a7e291401 100644 --- a/homeassistant/components/binary_sensor/wemo.py +++ b/homeassistant/components/binary_sensor/wemo.py @@ -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) diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index f5a7324d351..b37be3f6cb6 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -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) diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index 2ed0de66b18..ebdcdc6ca70 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -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 diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index bf038a62465..6931355ca0e 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -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) diff --git a/homeassistant/components/bmw_connected_drive.py b/homeassistant/components/bmw_connected_drive.py deleted file mode 100644 index 86048a56e22..00000000000 --- a/homeassistant/components/bmw_connected_drive.py +++ /dev/null @@ -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) diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py new file mode 100644 index 00000000000..a7ed262ac2c --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -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) diff --git a/homeassistant/components/bmw_connected_drive/services.yaml b/homeassistant/components/bmw_connected_drive/services.yaml new file mode 100644 index 00000000000..b9605429a8e --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/services.yaml @@ -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. diff --git a/homeassistant/components/calendar/caldav.py b/homeassistant/components/calendar/caldav.py index d70e7ff8946..6f92891c551 100644 --- a/homeassistant/components/calendar/caldav.py +++ b/homeassistant/components/calendar/caldav.py @@ -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 diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py index 098c7c70834..6c26c65ebe7 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -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.""" - service = self.calendar_service.get() + 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 diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index 61ff4345fbe..ebf0c7b1591 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -1,21 +1,26 @@ # Describes the format for available calendar services -todoist: - new_task: - description: Create a new task and add it to a project. - fields: - content: - description: The name of the task (Required). - example: Pick up the mail - project: - description: The name of the project this task should belong to. Defaults to Inbox (Optional). - example: Errands - labels: - description: Any labels that you want to apply to this task, separated by a comma (Optional). - example: Chores,Deliveries - priority: - description: The priority of this task, from 1 (normal) to 4 (urgent) (Optional). - example: 2 - due_date: - description: The day this task is due, in format YYYY-MM-DD (Optional). - example: "2018-04-01" +todoist_new_task: + description: Create a new task and add it to a project. + fields: + content: + 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. + example: Errands + labels: + 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). + 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. + example: "2018-04-01" diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py index c5ae1dd3c11..b70e44456db 100644 --- a/homeassistant/components/calendar/todoist.py +++ b/homeassistant/components/calendar/todoist.py @@ -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: diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 5321ec3d860..60f8979bb16 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -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()) diff --git a/homeassistant/components/camera/bloomsky.py b/homeassistant/components/camera/bloomsky.py index c3b4775b593..ef70692215d 100644 --- a/homeassistant/components/camera/bloomsky.py +++ b/homeassistant/components/camera/bloomsky.py @@ -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)]) diff --git a/homeassistant/components/camera/familyhub.py b/homeassistant/components/camera/familyhub.py new file mode 100644 index 00000000000..e78d341713b --- /dev/null +++ b/homeassistant/components/camera/familyhub.py @@ -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 diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index 2f5d8d28979..e11bd599e45 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -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( diff --git a/homeassistant/components/camera/local_file.py b/homeassistant/components/camera/local_file.py index 95d24c7d42e..95eade48568 100644 --- a/homeassistant/components/camera/local_file.py +++ b/homeassistant/components/camera/local_file.py @@ -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, + } diff --git a/homeassistant/components/camera/mqtt.py b/homeassistant/components/camera/mqtt.py index b7a7510e0eb..b2a27230a02 100644 --- a/homeassistant/components/camera/mqtt.py +++ b/homeassistant/components/camera/mqtt.py @@ -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): diff --git a/homeassistant/components/camera/netatmo.py b/homeassistant/components/camera/netatmo.py index 48f2710ce2e..bf2dfe39bd8 100644 --- a/homeassistant/components/camera/netatmo.py +++ b/homeassistant/components/camera/netatmo.py @@ -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 diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index d48f06539f4..3ae47ba5dee 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -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._profile_index = config.get(CONF_PROFILE) self._input = None - camera = 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: - _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): + 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://:@', 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): diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py index d045235c3ad..1984c21fadb 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -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 - image = await self.hass.async_add_job( - _resize_image, image, self._stream_opts) - write(image) + 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) + await write(image) + data = data[jpg_end + 2:] except asyncio.CancelledError: _LOGGER.debug("Stream closed by frontend.") req.close() diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index b548f3d1ada..544fd0e6b8a 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -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" - diff --git a/homeassistant/components/camera/xeoma.py b/homeassistant/components/camera/xeoma.py index 5836a9c94dc..cec04b52047 100644 --- a/homeassistant/components/camera/xeoma.py +++ b/homeassistant/components/camera/xeoma.py @@ -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: diff --git a/homeassistant/components/canary.py b/homeassistant/components/canary.py index 03825bf48a9..4d0fbe617b2 100644 --- a/homeassistant/components/canary.py +++ b/homeassistant/components/canary.py @@ -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__) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 7ea23f4fd65..ebe7cbbf2c1 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -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 diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index 6a4253ceca7..e64c2d5000e 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -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'] @@ -206,7 +212,7 @@ class Thermostat(ClimateDevice): if event['type'] == 'hold': if event['holdClimateRef'] == 'away': if int(event['endDate'][0:4]) - \ - int(event['startDate'][0:4]) <= 1: + int(event['startDate'][0:4]) <= 1: # A temporary hold from away climate is a hold return 'away' # A permanent hold from away climate @@ -228,7 +234,7 @@ class Thermostat(ClimateDevice): def current_operation(self): """Return current operation.""" if self.operation_mode == 'auxHeatOnly' or \ - self.operation_mode == 'heatPump': + self.operation_mode == 'heatPump': return STATE_HEAT return self.operation_mode @@ -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) diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index 5c0a3530006..820e715b00d 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -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__) diff --git a/homeassistant/components/climate/fritzbox.py b/homeassistant/components/climate/fritzbox.py new file mode 100755 index 00000000000..839da8c9d53 --- /dev/null +++ b/homeassistant/components/climate/fritzbox.py @@ -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() diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index b5d3c3f7c25..6b7f6cb2afc 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -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): diff --git a/homeassistant/components/climate/hive.py b/homeassistant/components/climate/hive.py index 760ef131049..eb3aecae3a1 100644 --- a/homeassistant/components/climate/hive.py +++ b/homeassistant/components/climate/hive.py @@ -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) diff --git a/homeassistant/components/climate/homematicip_cloud.py b/homeassistant/components/climate/homematicip_cloud.py new file mode 100644 index 00000000000..bf96f1f746d --- /dev/null +++ b/homeassistant/components/climate/homematicip_cloud.py @@ -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) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 20d93e3116a..11a507aded2 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -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__) diff --git a/homeassistant/components/climate/maxcube.py b/homeassistant/components/climate/maxcube.py index 067d11437b2..712ebb4f4ce 100644 --- a/homeassistant/components/climate/maxcube.py +++ b/homeassistant/components/climate/maxcube.py @@ -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) - 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)) + if cube.is_thermostat(device) or cube.is_wallthermostat(device): + 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): diff --git a/homeassistant/components/climate/modbus.py b/homeassistant/components/climate/modbus.py new file mode 100644 index 00000000000..7d392e5a40f --- /dev/null +++ b/homeassistant/components/climate/modbus.py @@ -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]) diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py index b526d8b066c..9fab56c61ac 100644 --- a/homeassistant/components/climate/mysensors.py +++ b/homeassistant/components/climate/mysensors.py @@ -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]] diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index e5c21158acb..696f1479c08 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -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) + _LOGGER.debug("Nest set_temperature-output-value=%s", temp) try: - self.device.target = temp - except nest.nest.APIError: - _LOGGER.error("An error occured while setting the temperature") + if temp is not None: + self.device.target = temp + 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 diff --git a/homeassistant/components/climate/netatmo.py b/homeassistant/components/climate/netatmo.py index 5d54b39e773..49452662fc4 100644 --- a/homeassistant/components/climate/netatmo.py +++ b/homeassistant/components/climate/netatmo.py @@ -13,7 +13,6 @@ from homeassistant.components.climate import ( STATE_HEAT, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE) from homeassistant.util import Throttle -from homeassistant.loader import get_component import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['netatmo'] @@ -42,7 +41,7 @@ SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the NetAtmo Thermostat.""" - netatmo = get_component('netatmo') + netatmo = hass.components.netatmo device = config.get(CONF_RELAY) import lnetatmo diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index e2a455aefc7..b3fff0dd796 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -19,13 +19,13 @@ from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ClimateDevice, DOMAIN, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, - SUPPORT_ON_OFF) + SUPPORT_ON_OFF, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.temperature import convert as convert_temperature -REQUIREMENTS = ['pysensibo==1.0.2'] +REQUIREMENTS = ['pysensibo==1.0.3'] _LOGGER = logging.getLogger(__name__) @@ -154,7 +154,8 @@ class SensiboClimate(ClimateDevice): @property def device_state_attributes(self): """Return the state attributes.""" - return {ATTR_CURRENT_HUMIDITY: self.current_humidity} + return {ATTR_CURRENT_HUMIDITY: self.current_humidity, + 'battery': self.current_battery} @property def temperature_unit(self): @@ -191,6 +192,11 @@ class SensiboClimate(ClimateDevice): """Return the current humidity.""" return self._measurements['humidity'] + @property + def current_battery(self): + """Return the current battery voltage.""" + return self._measurements.get('batteryVoltage') + @property def current_temperature(self): """Return the current temperature.""" @@ -240,13 +246,13 @@ class SensiboClimate(ClimateDevice): def min_temp(self): """Return the minimum temperature.""" return self._temperatures_list[0] \ - if self._temperatures_list else super().min_temp + if self._temperatures_list else DEFAULT_MIN_TEMP @property def max_temp(self): """Return the maximum temperature.""" return self._temperatures_list[-1] \ - if self._temperatures_list else super().max_temp + if self._temperatures_list else DEFAULT_MAX_TEMP @property def unique_id(self): diff --git a/homeassistant/components/climate/tado.py b/homeassistant/components/climate/tado.py index 437c8ec3371..59da425553a 100644 --- a/homeassistant/components/climate/tado.py +++ b/homeassistant/components/climate/tado.py @@ -8,7 +8,8 @@ import logging from homeassistant.const import (PRECISION_TENTHS, TEMP_CELSIUS) from homeassistant.components.climate import ( - ClimateDevice, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) + ClimateDevice, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, + DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP) from homeassistant.const import ATTR_TEMPERATURE from homeassistant.components.tado import DATA_TADO @@ -232,16 +233,16 @@ class TadoClimate(ClimateDevice): """Return the minimum temperature.""" if self._min_temp: return self._min_temp - # get default temp from super class - return super().min_temp + + return DEFAULT_MIN_TEMP @property def max_temp(self): """Return the maximum temperature.""" if self._max_temp: return self._max_temp - # Get default temp from super class - return super().max_temp + + return DEFAULT_MAX_TEMP def update(self): """Update the state of this climate device.""" diff --git a/homeassistant/components/climate/venstar.py b/homeassistant/components/climate/venstar.py index 6e63cc4092b..c2b82e1cc84 100644 --- a/homeassistant/components/climate/venstar.py +++ b/homeassistant/components/climate/venstar.py @@ -11,9 +11,11 @@ import voluptuous as vol from homeassistant.components.climate import ( ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, PLATFORM_SCHEMA, STATE_AUTO, STATE_COOL, STATE_HEAT, SUPPORT_FAN_MODE, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_HUMIDITY, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_HIGH, - SUPPORT_TARGET_TEMPERATURE_LOW, ClimateDevice) + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_HUMIDITY, SUPPORT_AWAY_MODE, + SUPPORT_TARGET_HUMIDITY_HIGH, SUPPORT_TARGET_HUMIDITY_LOW, + SUPPORT_HOLD_MODE, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW, + ClimateDevice) from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_TIMEOUT, CONF_USERNAME, PRECISION_WHOLE, STATE_OFF, STATE_ON, TEMP_CELSIUS, @@ -27,14 +29,20 @@ _LOGGER = logging.getLogger(__name__) ATTR_FAN_STATE = 'fan_state' ATTR_HVAC_STATE = 'hvac_state' +CONF_HUMIDIFIER = 'humidifier' + DEFAULT_SSL = False VALID_FAN_STATES = [STATE_ON, STATE_AUTO] VALID_THERMOSTAT_MODES = [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_AUTO] +HOLD_MODE_OFF = 'off' +HOLD_MODE_TEMPERATURE = 'temperature' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_HUMIDIFIER, default=True): cv.boolean, vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_TIMEOUT, default=5): vol.All(vol.Coerce(int), vol.Range(min=1)), @@ -50,6 +58,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): password = config.get(CONF_PASSWORD) host = config.get(CONF_HOST) timeout = config.get(CONF_TIMEOUT) + humidifier = config.get(CONF_HUMIDIFIER) if config.get(CONF_SSL): proto = 'https' @@ -60,15 +69,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): addr=host, timeout=timeout, user=username, password=password, proto=proto) - add_devices([VenstarThermostat(client)], True) + add_devices([VenstarThermostat(client, humidifier)], True) class VenstarThermostat(ClimateDevice): """Representation of a Venstar thermostat.""" - def __init__(self, client): + def __init__(self, client, humidifier): """Initialize the thermostat.""" self._client = client + self._humidifier = humidifier def update(self): """Update the data from the thermostat.""" @@ -81,14 +91,18 @@ class VenstarThermostat(ClimateDevice): def supported_features(self): """Return the list of supported features.""" features = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE | - SUPPORT_OPERATION_MODE) + SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE | + SUPPORT_HOLD_MODE) if self._client.mode == self._client.MODE_AUTO: features |= (SUPPORT_TARGET_TEMPERATURE_HIGH | SUPPORT_TARGET_TEMPERATURE_LOW) - if self._client.hum_active == 1: - features |= SUPPORT_TARGET_HUMIDITY + if (self._humidifier and + hasattr(self._client, 'hum_active')): + features |= (SUPPORT_TARGET_HUMIDITY | + SUPPORT_TARGET_HUMIDITY_HIGH | + SUPPORT_TARGET_HUMIDITY_LOW) return features @@ -197,6 +211,18 @@ class VenstarThermostat(ClimateDevice): """Return the maximum humidity. Hardcoded to 60 in API.""" return 60 + @property + def is_away_mode_on(self): + """Return the status of away mode.""" + return self._client.away == self._client.AWAY_AWAY + + @property + def current_hold_mode(self): + """Return the status of hold mode.""" + if self._client.schedule == 0: + return HOLD_MODE_TEMPERATURE + return HOLD_MODE_OFF + def _set_operation_mode(self, operation_mode): """Change the operation mode (internal).""" if operation_mode == STATE_HEAT: @@ -259,3 +285,30 @@ class VenstarThermostat(ClimateDevice): if not success: _LOGGER.error("Failed to change the target humidity level") + + def set_hold_mode(self, hold_mode): + """Set the hold mode.""" + if hold_mode == HOLD_MODE_TEMPERATURE: + success = self._client.set_schedule(0) + elif hold_mode == HOLD_MODE_OFF: + success = self._client.set_schedule(1) + else: + _LOGGER.error("Unknown hold mode: %s", hold_mode) + success = False + + if not success: + _LOGGER.error("Failed to change the schedule/hold state") + + def turn_away_mode_on(self): + """Activate away mode.""" + success = self._client.set_away(self._client.AWAY_AWAY) + + if not success: + _LOGGER.error("Failed to activate away mode") + + def turn_away_mode_off(self): + """Deactivate away mode.""" + success = self._client.set_away(self._client.AWAY_HOME) + + if not success: + _LOGGER.error("Failed to deactivate away mode") diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index 8c66567a4aa..c67e032c149 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -190,7 +190,7 @@ class WinkThermostat(WinkDevice, ClimateDevice): @property def cool_on(self): """Return whether or not the heat is actually heating.""" - return self.wink.heat_on() + return self.wink.cool_on() @property def current_operation(self): diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index adf0b8f51b6..8c1a9751c19 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -21,6 +21,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.google_assistant import helpers as ga_h +from homeassistant.components.google_assistant import const as ga_c from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS @@ -37,6 +38,7 @@ CONF_FILTER = 'filter' CONF_GOOGLE_ACTIONS = 'google_actions' CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' +CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] @@ -51,7 +53,8 @@ ALEXA_ENTITY_SCHEMA = vol.Schema({ GOOGLE_ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]) + vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ga_c.CONF_ROOM_HINT): cv.string, }) ASSISTANT_SCHEMA = vol.Schema({ @@ -75,6 +78,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_USER_POOL_ID): str, vol.Optional(CONF_REGION): str, vol.Optional(CONF_RELAYER): str, + vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, }), @@ -110,7 +114,7 @@ class Cloud: def __init__(self, hass, mode, alexa, google_actions, cognito_client_id=None, user_pool_id=None, region=None, - relayer=None): + relayer=None, google_actions_sync_url=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode @@ -128,6 +132,7 @@ class Cloud: self.user_pool_id = user_pool_id self.region = region self.relayer = relayer + self.google_actions_sync_url = google_actions_sync_url else: info = SERVERS[mode] @@ -136,6 +141,7 @@ class Cloud: self.user_pool_id = info['user_pool_id'] self.region = info['region'] self.relayer = info['relayer'] + self.google_actions_sync_url = info['google_actions_sync_url'] @property def is_logged_in(self): diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 99075d3d02d..82128206d47 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -8,7 +8,9 @@ SERVERS = { 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', 'user_pool_id': 'us-east-1_87ll5WOP8', 'region': 'us-east-1', - 'relayer': 'wss://cloud.hass.io:8000/websocket' + 'relayer': 'wss://cloud.hass.io:8000/websocket', + 'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.' + 'amazonaws.com/prod/smart_home_sync'), } } diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 3065de24180..a4b3b59f333 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -16,9 +16,9 @@ from .const import DOMAIN, REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def async_setup(hass): +async def async_setup(hass): """Initialize the HTTP API.""" + hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) hass.http.register_view(CloudAccountView) @@ -38,12 +38,11 @@ _CLOUD_ERRORS = { def _handle_cloud_errors(handler): """Handle auth errors.""" - @asyncio.coroutine @wraps(handler) - def error_handler(view, request, *args, **kwargs): + async def error_handler(view, request, *args, **kwargs): """Handle exceptions that raise from the wrapped request handler.""" try: - result = yield from handler(view, request, *args, **kwargs) + result = await handler(view, request, *args, **kwargs) return result except (auth_api.CloudError, asyncio.TimeoutError) as err: @@ -57,6 +56,31 @@ def _handle_cloud_errors(handler): return error_handler +class GoogleActionsSyncView(HomeAssistantView): + """Trigger a Google Actions Smart Home Sync.""" + + url = '/api/cloud/google_actions/sync' + name = 'api:cloud:google_actions/sync' + + @_handle_cloud_errors + async def post(self, request): + """Trigger a Google Actions sync.""" + hass = request.app['hass'] + cloud = hass.data[DOMAIN] + websession = hass.helpers.aiohttp_client.async_get_clientsession() + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + await hass.async_add_job(auth_api.check_token, cloud) + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + req = await websession.post( + cloud.google_actions_sync_url, headers={ + 'authorization': cloud.id_token + }) + + return self.json({}, status_code=req.status) + + class CloudLoginView(HomeAssistantView): """Login to Home Assistant cloud.""" @@ -68,19 +92,18 @@ class CloudLoginView(HomeAssistantView): vol.Required('email'): str, vol.Required('password'): str, })) - @asyncio.coroutine - def post(self, request, data): + async def post(self, request, data): """Handle login request.""" hass = request.app['hass'] cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job(auth_api.login, cloud, data['email'], - data['password']) + await hass.async_add_job(auth_api.login, cloud, data['email'], + data['password']) hass.async_add_job(cloud.iot.connect) # Allow cloud to start connecting. - yield from asyncio.sleep(0, loop=hass.loop) + await asyncio.sleep(0, loop=hass.loop) return self.json(_account_data(cloud)) @@ -91,14 +114,13 @@ class CloudLogoutView(HomeAssistantView): name = 'api:cloud:logout' @_handle_cloud_errors - @asyncio.coroutine - def post(self, request): + async def post(self, request): """Handle logout request.""" hass = request.app['hass'] cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from cloud.logout() + await cloud.logout() return self.json_message('ok') @@ -109,8 +131,7 @@ class CloudAccountView(HomeAssistantView): url = '/api/cloud/account' name = 'api:cloud:account' - @asyncio.coroutine - def get(self, request): + async def get(self, request): """Get account info.""" hass = request.app['hass'] cloud = hass.data[DOMAIN] @@ -132,14 +153,13 @@ class CloudRegisterView(HomeAssistantView): vol.Required('email'): str, vol.Required('password'): vol.All(str, vol.Length(min=6)), })) - @asyncio.coroutine - def post(self, request, data): + async def post(self, request, data): """Handle registration request.""" hass = request.app['hass'] cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job( + await hass.async_add_job( auth_api.register, cloud, data['email'], data['password']) return self.json_message('ok') @@ -155,14 +175,13 @@ class CloudResendConfirmView(HomeAssistantView): @RequestDataValidator(vol.Schema({ vol.Required('email'): str, })) - @asyncio.coroutine - def post(self, request, data): + async def post(self, request, data): """Handle resending confirm email code request.""" hass = request.app['hass'] cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job( + await hass.async_add_job( auth_api.resend_email_confirm, cloud, data['email']) return self.json_message('ok') @@ -178,14 +197,13 @@ class CloudForgotPasswordView(HomeAssistantView): @RequestDataValidator(vol.Schema({ vol.Required('email'): str, })) - @asyncio.coroutine - def post(self, request, data): + async def post(self, request, data): """Handle forgot password request.""" hass = request.app['hass'] cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job( + await hass.async_add_job( auth_api.forgot_password, cloud, data['email']) return self.json_message('ok') diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 7cf8e50e866..12b81c9003b 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -185,7 +185,7 @@ class CloudIoT: yield from client.send_json(response) except client_exceptions.WSServerHandshakeError as err: - if err.code == 401: + if err.status == 401: disconnect_warn = 'Invalid auth.' self.close_requested = True # Should we notify user? diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 601b12ffe4a..5a8800d9583 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,49 +14,30 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script', - 'entity_registry') + 'entity_registry', 'config_entries') ON_DEMAND = ('zwave',) -FEATURE_FLAGS = ('config_entries',) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the config component.""" - global SECTIONS - - yield from hass.components.frontend.async_register_built_in_panel( + await hass.components.frontend.async_register_built_in_panel( 'config', 'config', 'mdi:settings') - # Temporary way of allowing people to opt-in for unreleased config sections - for key, value in config.get(DOMAIN, {}).items(): - if key in FEATURE_FLAGS and value: - SECTIONS += (key,) - - @asyncio.coroutine - def setup_panel(panel_name): + async def setup_panel(panel_name): """Set up a panel.""" - panel = yield from async_prepare_setup_platform( + panel = await async_prepare_setup_platform( hass, config, DOMAIN, panel_name) if not panel: return - success = yield from panel.async_setup(hass) + success = await panel.async_setup(hass) if success: key = '{}.{}'.format(DOMAIN, panel_name) hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: key}) hass.config.components.add(key) - tasks = [setup_panel(panel_name) for panel_name in SECTIONS] - - for panel_name in ON_DEMAND: - if panel_name in hass.config.components: - tasks.append(setup_panel(panel_name)) - - if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) - @callback def component_loaded(event): """Respond to components being loaded.""" @@ -66,6 +47,15 @@ def async_setup(hass, config): hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) + tasks = [setup_panel(panel_name) for panel_name in SECTIONS] + + for panel_name in ON_DEMAND: + if panel_name in hass.config.components: + tasks.append(setup_panel(panel_name)) + + if tasks: + await asyncio.wait(tasks, loop=hass.loop) + return True @@ -94,11 +84,10 @@ class BaseEditConfigView(HomeAssistantView): """Set value.""" raise NotImplementedError - @asyncio.coroutine - def get(self, request, config_key): + async def get(self, request, config_key): """Fetch device specific config.""" hass = request.app['hass'] - current = yield from self.read_config(hass) + current = await self.read_config(hass) value = self._get_value(hass, current, config_key) if value is None: @@ -106,11 +95,10 @@ class BaseEditConfigView(HomeAssistantView): return self.json(value) - @asyncio.coroutine - def post(self, request, config_key): + async def post(self, request, config_key): """Validate config and return results.""" try: - data = yield from request.json() + data = await request.json() except ValueError: return self.json_message('Invalid JSON specified', 400) @@ -129,10 +117,10 @@ class BaseEditConfigView(HomeAssistantView): hass = request.app['hass'] path = hass.config.path(self.path) - current = yield from self.read_config(hass) + current = await self.read_config(hass) self._write_value(hass, current, config_key, data) - yield from hass.async_add_job(_write, path, current) + await hass.async_add_job(_write, path, current) if self.post_write_hook is not None: hass.async_add_job(self.post_write_hook(hass)) @@ -141,10 +129,9 @@ class BaseEditConfigView(HomeAssistantView): 'result': 'ok', }) - @asyncio.coroutine - def read_config(self, hass): + async def read_config(self, hass): """Read the config.""" - current = yield from hass.async_add_job( + current = await hass.async_add_job( _read, hass.config.path(self.path)) if not current: current = self._empty_config() diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 6ede91e9b66..223159eb415 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,6 +1,9 @@ """Provide configuration end points for Automations.""" import asyncio +from collections import OrderedDict +import uuid +from homeassistant.const import CONF_ID from homeassistant.components.config import EditIdBasedConfigView from homeassistant.components.automation import ( PLATFORM_SCHEMA, DOMAIN, async_reload) @@ -13,8 +16,43 @@ CONFIG_PATH = 'automations.yaml' @asyncio.coroutine def async_setup(hass): """Set up the Automation config API.""" - hass.http.register_view(EditIdBasedConfigView( + hass.http.register_view(EditAutomationConfigView( DOMAIN, 'config', CONFIG_PATH, cv.string, PLATFORM_SCHEMA, post_write_hook=async_reload )) return True + + +class EditAutomationConfigView(EditIdBasedConfigView): + """Edit automation config.""" + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + index = None + for index, cur_value in enumerate(data): + # When people copy paste their automations to the config file, + # they sometimes forget to add IDs. Fix it here. + if CONF_ID not in cur_value: + cur_value[CONF_ID] = uuid.uuid4().hex + + elif cur_value[CONF_ID] == config_key: + break + else: + cur_value = OrderedDict() + cur_value[CONF_ID] = config_key + index = len(data) + data.append(cur_value) + + # Iterate through some keys that we want to have ordered in the output + updated_value = OrderedDict() + for key in ('id', 'alias', 'trigger', 'condition', 'action'): + if key in cur_value: + updated_value[key] = cur_value[key] + if key in new_value: + updated_value[key] = new_value[key] + + # We cover all current fields above, but just in case we start + # supporting more fields in the future. + updated_value.update(cur_value) + updated_value.update(new_value) + data[index] = updated_value diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index aa42325b75b..d2aa918eda2 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,11 +1,10 @@ """Http views to control the config manager.""" import asyncio -import voluptuous as vol - -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.helpers.data_entry_flow import ( + FlowManagerIndexView, FlowManagerResourceView) REQUIREMENTS = ['voluptuous-serialize==1'] @@ -16,15 +15,17 @@ def async_setup(hass): """Enable the Home Assistant views.""" hass.http.register_view(ConfigManagerEntryIndexView) hass.http.register_view(ConfigManagerEntryResourceView) - hass.http.register_view(ConfigManagerFlowIndexView) - hass.http.register_view(ConfigManagerFlowResourceView) + hass.http.register_view( + ConfigManagerFlowIndexView(hass.config_entries.flow)) + hass.http.register_view( + ConfigManagerFlowResourceView(hass.config_entries.flow)) hass.http.register_view(ConfigManagerAvailableFlowView) return True def _prepare_json(result): """Convert result for JSON.""" - if result['type'] != config_entries.RESULT_TYPE_FORM: + if result['type'] != data_entry_flow.RESULT_TYPE_FORM: return result import voluptuous_serialize @@ -78,7 +79,7 @@ class ConfigManagerEntryResourceView(HomeAssistantView): return self.json(result) -class ConfigManagerFlowIndexView(HomeAssistantView): +class ConfigManagerFlowIndexView(FlowManagerIndexView): """View to create config flows.""" url = '/api/config/config_entries/flow' @@ -94,81 +95,16 @@ class ConfigManagerFlowIndexView(HomeAssistantView): hass = request.app['hass'] return self.json([ - flow for flow in hass.config_entries.flow.async_progress() - if flow['source'] != config_entries.SOURCE_USER]) - - @RequestDataValidator(vol.Schema({ - vol.Required('domain'): str, - })) - @asyncio.coroutine - def post(self, request, data): - """Handle a POST request.""" - hass = request.app['hass'] - - try: - result = yield from hass.config_entries.flow.async_init( - data['domain']) - except config_entries.UnknownHandler: - return self.json_message('Invalid handler specified', 404) - except config_entries.UnknownStep: - return self.json_message('Handler does not support init', 400) - - result = _prepare_json(result) - - return self.json(result) + flw for flw in hass.config_entries.flow.async_progress() + if flw['source'] != data_entry_flow.SOURCE_USER]) -class ConfigManagerFlowResourceView(HomeAssistantView): +class ConfigManagerFlowResourceView(FlowManagerResourceView): """View to interact with the flow manager.""" url = '/api/config/config_entries/flow/{flow_id}' name = 'api:config:config_entries:flow:resource' - @asyncio.coroutine - def get(self, request, flow_id): - """Get the current state of a flow.""" - hass = request.app['hass'] - - try: - result = yield from hass.config_entries.flow.async_configure( - flow_id) - except config_entries.UnknownFlow: - return self.json_message('Invalid flow specified', 404) - - result = _prepare_json(result) - - return self.json(result) - - @RequestDataValidator(vol.Schema(dict), allow_empty=True) - @asyncio.coroutine - def post(self, request, flow_id, data): - """Handle a POST request.""" - hass = request.app['hass'] - - try: - result = yield from hass.config_entries.flow.async_configure( - flow_id, data) - except config_entries.UnknownFlow: - return self.json_message('Invalid flow specified', 404) - except vol.Invalid: - return self.json_message('User input malformed', 400) - - result = _prepare_json(result) - - return self.json(result) - - @asyncio.coroutine - def delete(self, request, flow_id): - """Cancel a flow in progress.""" - hass = request.app['hass'] - - try: - hass.config_entries.flow.async_abort(flow_id) - except config_entries.UnknownFlow: - return self.json_message('Invalid flow specified', 404) - - return self.json_message('Flow aborted') - class ConfigManagerAvailableFlowView(HomeAssistantView): """View to query available flows.""" diff --git a/homeassistant/components/config_entry_example/.translations/en.json b/homeassistant/components/config_entry_example/.translations/en.json deleted file mode 100644 index ec24d01ebc8..00000000000 --- a/homeassistant/components/config_entry_example/.translations/en.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "config": { - "error": { - "invalid_object_id": "Invalid object ID" - }, - "step": { - "init": { - "data": { - "object_id": "Object ID" - }, - "description": "Please enter an object_id for the test entity.", - "title": "Pick object id" - }, - "name": { - "data": { - "name": "Name" - }, - "description": "Please enter a name for the test entity.", - "title": "Name of the entity" - } - }, - "title": "Config Entry Example" - } -} \ No newline at end of file diff --git a/homeassistant/components/config_entry_example/__init__.py b/homeassistant/components/config_entry_example/__init__.py deleted file mode 100644 index 3ebfdc3a183..00000000000 --- a/homeassistant/components/config_entry_example/__init__.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Example component to show how config entries work.""" - -import asyncio - -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.util import slugify - - -DOMAIN = 'config_entry_example' - - -@asyncio.coroutine -def async_setup(hass, config): - """Setup for our example component.""" - return True - - -@asyncio.coroutine -def async_setup_entry(hass, entry): - """Initialize an entry.""" - entity_id = '{}.{}'.format(DOMAIN, entry.data['object_id']) - hass.states.async_set(entity_id, 'loaded', { - ATTR_FRIENDLY_NAME: entry.data['name'] - }) - - # Indicate setup was successful. - return True - - -@asyncio.coroutine -def async_unload_entry(hass, entry): - """Unload an entry.""" - entity_id = '{}.{}'.format(DOMAIN, entry.data['object_id']) - hass.states.async_remove(entity_id) - - # Indicate unload was successful. - return True - - -@config_entries.HANDLERS.register(DOMAIN) -class ExampleConfigFlow(config_entries.ConfigFlowHandler): - """Handle an example configuration flow.""" - - VERSION = 1 - - def __init__(self): - """Initialize a Hue config handler.""" - self.object_id = None - - @asyncio.coroutine - def async_step_init(self, user_input=None): - """Start config flow.""" - errors = None - if user_input is not None: - object_id = user_input['object_id'] - - if object_id != '' and object_id == slugify(object_id): - self.object_id = user_input['object_id'] - return (yield from self.async_step_name()) - - errors = { - 'object_id': 'invalid_object_id' - } - - return self.async_show_form( - step_id='init', - data_schema=vol.Schema({ - 'object_id': str - }), - errors=errors - ) - - @asyncio.coroutine - def async_step_name(self, user_input=None): - """Ask user to enter the name.""" - errors = None - if user_input is not None: - name = user_input['name'] - - if name != '': - return self.async_create_entry( - title=name, - data={ - 'name': name, - 'object_id': self.object_id, - } - ) - - return self.async_show_form( - step_id='name', - data_schema=vol.Schema({ - 'name': str - }), - errors=errors - ) diff --git a/homeassistant/components/config_entry_example/strings.json b/homeassistant/components/config_entry_example/strings.json deleted file mode 100644 index a7a8cd4025b..00000000000 --- a/homeassistant/components/config_entry_example/strings.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "config": { - "title": "Config Entry Example", - "step": { - "init": { - "title": "Pick object id", - "description": "Please enter an object_id for the test entity.", - "data": { - "object_id": "Object ID" - } - }, - "name": { - "title": "Name of the entity", - "description": "Please enter a name for the test entity.", - "data": { - "name": "Name" - } - } - }, - "error": { - "invalid_object_id": "Invalid object ID" - } - } -} diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation/__init__.py similarity index 83% rename from homeassistant/components/conversation.py rename to homeassistant/components/conversation/__init__.py index e96694ce0a3..9cb00a84583 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation/__init__.py @@ -13,10 +13,14 @@ from homeassistant import core from homeassistant.components import http from homeassistant.components.http.data_validator import ( RequestDataValidator) +from homeassistant.components.cover import (INTENT_OPEN_COVER, + INTENT_CLOSE_COVER) +from homeassistant.const import EVENT_COMPONENT_LOADED +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers import intent - from homeassistant.loader import bind_hass +from homeassistant.setup import (ATTR_COMPONENT) _LOGGER = logging.getLogger(__name__) @@ -28,6 +32,13 @@ DOMAIN = 'conversation' REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') REGEX_TYPE = type(re.compile('')) +UTTERANCES = { + 'cover': { + INTENT_OPEN_COVER: ['Open [the] [a] [an] {name}[s]'], + INTENT_CLOSE_COVER: ['Close [the] [a] [an] {name}[s]'] + } +} + SERVICE_PROCESS = 'process' SERVICE_PROCESS_SCHEMA = vol.Schema({ @@ -85,6 +96,7 @@ async def async_setup(hass, config): async def process(service): """Parse text into commands.""" text = service.data[ATTR_TEXT] + _LOGGER.debug('Processing: <%s>', text) try: await _process(hass, text) except intent.IntentHandleError as err: @@ -112,6 +124,25 @@ async def async_setup(hass, config): '[the] [a] [an] {name}[s] toggle', ]) + @callback + def register_utterances(component): + """Register utterances for a component.""" + if component not in UTTERANCES: + return + for intent_type, sentences in UTTERANCES[component].items(): + async_register(hass, intent_type, sentences) + + @callback + def component_loaded(event): + """Handle a new component loaded.""" + register_utterances(event.data[ATTR_COMPONENT]) + + hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) + + # Check already loaded components. + for component in hass.config.components: + register_utterances(component) + return True diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml new file mode 100644 index 00000000000..a1b980d8e05 --- /dev/null +++ b/homeassistant/components/conversation/services.yaml @@ -0,0 +1,10 @@ +# Describes the format for available component services + +process: + description: Launch a conversation from a transcribed text. + fields: + text: + description: Transcribed text + example: Turn all lights on + + diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 2df17a4e50a..03e5b273468 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -9,9 +9,9 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) +from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import async_get_last_state @@ -94,9 +94,8 @@ def async_reset(hass, entity_id): DOMAIN, SERVICE_RESET, {ATTR_ENTITY_ID: entity_id})) -@asyncio.coroutine -def async_setup(hass, config): - """Set up a counter.""" +async def async_setup(hass, config): + """Set up the counters.""" component = EntityComponent(_LOGGER, DOMAIN, hass) entities = [] @@ -115,8 +114,7 @@ def async_setup(hass, config): if not entities: return False - @asyncio.coroutine - def async_handler_service(service): + async def async_handler_service(service): """Handle a call to the counter services.""" target_counters = component.async_extract_from_service(service) @@ -129,7 +127,7 @@ def async_setup(hass, config): tasks = [getattr(counter, attr)() for counter in target_counters] if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_INCREMENT, async_handler_service) @@ -138,7 +136,7 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_RESET, async_handler_service) - yield from component.async_add_entities(entities) + await component.async_add_entities(entities) return True @@ -181,30 +179,26 @@ class Counter(Entity): ATTR_STEP: self._step, } - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Call when entity about to be added to Home Assistant.""" # If not None, we got an initial value. if self._state is not None: return - state = yield from async_get_last_state(self.hass, self.entity_id) + state = await async_get_last_state(self.hass, self.entity_id) self._state = state and state.state == state - @asyncio.coroutine - def async_decrement(self): + async def async_decrement(self): """Decrement the counter.""" self._state -= self._step - yield from self.async_update_ha_state() + await self.async_update_ha_state() - @asyncio.coroutine - def async_increment(self): + async def async_increment(self): """Increment a counter.""" self._state += self._step - yield from self.async_update_ha_state() + await self.async_update_ha_state() - @asyncio.coroutine - def async_reset(self): + async def async_reset(self): """Reset a counter.""" self._state = self._initial - yield from self.async_update_ha_state() + await self.async_update_ha_state() diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index b24361d8293..e4c8f5634cf 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -17,6 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv from homeassistant.components import group +from homeassistant.helpers import intent from homeassistant.const import ( SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_CLOSE_COVER_TILT, @@ -55,6 +56,9 @@ ATTR_CURRENT_TILT_POSITION = 'current_tilt_position' ATTR_POSITION = 'position' ATTR_TILT_POSITION = 'tilt_position' +INTENT_OPEN_COVER = 'HassOpenCover' +INTENT_CLOSE_COVER = 'HassCloseCover' + COVER_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) @@ -181,6 +185,12 @@ async def async_setup(hass, config): hass.services.async_register( DOMAIN, service_name, async_handle_cover_service, schema=schema) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + INTENT_OPEN_COVER, DOMAIN, SERVICE_OPEN_COVER, + "Opened {}")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + INTENT_CLOSE_COVER, DOMAIN, SERVICE_CLOSE_COVER, + "Closed {}")) return True diff --git a/homeassistant/components/cover/gogogate2.py b/homeassistant/components/cover/gogogate2.py new file mode 100644 index 00000000000..2b91591e71b --- /dev/null +++ b/homeassistant/components/cover/gogogate2.py @@ -0,0 +1,117 @@ +""" +Support for Gogogate2 garage Doors. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/cover.gogogate2/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.cover import ( + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, STATE_CLOSED, + CONF_IP_ADDRESS, CONF_NAME) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pygogogate2==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'gogogate2' + +NOTIFICATION_ID = 'gogogate2_notification' +NOTIFICATION_TITLE = 'Gogogate2 Cover Setup' + +COVER_SCHEMA = vol.Schema({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Gogogate2 component.""" + from pygogogate2 import Gogogate2API as pygogogate2 + + ip_address = config.get(CONF_IP_ADDRESS) + name = config.get(CONF_NAME) + password = config.get(CONF_PASSWORD) + username = config.get(CONF_USERNAME) + + mygogogate2 = pygogogate2(username, password, ip_address) + + try: + devices = mygogogate2.get_devices() + if devices is False: + raise ValueError( + "Username or Password is incorrect or no devices found") + + add_devices(MyGogogate2Device( + mygogogate2, door, name) for door in devices) + + except (TypeError, KeyError, NameError, ValueError) as ex: + _LOGGER.error("%s", ex) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + + +class MyGogogate2Device(CoverDevice): + """Representation of a Gogogate2 cover.""" + + def __init__(self, mygogogate2, device, name): + """Initialize with API object, device id.""" + self.mygogogate2 = mygogogate2 + self.device_id = device['door'] + self._name = name or device['name'] + self._status = device['status'] + self._available = None + + @property + def name(self): + """Return the name of the garage door if any.""" + return self._name if self._name else DEFAULT_NAME + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self._status == STATE_CLOSED + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'garage' + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._available + + def close_cover(self, **kwargs): + """Issue close command to cover.""" + self.mygogogate2.close_device(self.device_id) + + def open_cover(self, **kwargs): + """Issue open command to cover.""" + self.mygogogate2.open_device(self.device_id) + + def update(self): + """Update status of cover.""" + try: + self._status = self.mygogogate2.get_status(self.device_id) + self._available = True + except (TypeError, KeyError, NameError, ValueError) as ex: + _LOGGER.error("%s", ex) + self._status = None + self._available = False diff --git a/homeassistant/components/cover/group.py b/homeassistant/components/cover/group.py new file mode 100755 index 00000000000..c1ea33a9cc7 --- /dev/null +++ b/homeassistant/components/cover/group.py @@ -0,0 +1,271 @@ +""" +This platform allows several cover to be grouped into one cover. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.group/ +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.cover import ( + DOMAIN, PLATFORM_SCHEMA, CoverDevice, ATTR_POSITION, + ATTR_CURRENT_POSITION, ATTR_TILT_POSITION, ATTR_CURRENT_TILT_POSITION, + SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP, SUPPORT_SET_POSITION, + SUPPORT_OPEN_TILT, SUPPORT_CLOSE_TILT, + SUPPORT_STOP_TILT, SUPPORT_SET_TILT_POSITION, + SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_CLOSE_COVER_TILT, + SERVICE_STOP_COVER_TILT, SERVICE_SET_COVER_TILT_POSITION) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + CONF_ENTITIES, CONF_NAME, STATE_CLOSED) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_state_change + +_LOGGER = logging.getLogger(__name__) + +KEY_OPEN_CLOSE = 'open_close' +KEY_STOP = 'stop' +KEY_POSITION = 'position' + +DEFAULT_NAME = 'Cover Group' + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Group Cover platform.""" + async_add_devices( + [CoverGroup(config[CONF_NAME], config[CONF_ENTITIES])]) + + +class CoverGroup(CoverDevice): + """Representation of a CoverGroup.""" + + def __init__(self, name, entities): + """Initialize a CoverGroup entity.""" + self._name = name + self._is_closed = False + self._cover_position = 100 + self._tilt_position = None + self._supported_features = 0 + self._assumed_state = True + + self._entities = entities + self._covers = {KEY_OPEN_CLOSE: set(), KEY_STOP: set(), + KEY_POSITION: set()} + self._tilts = {KEY_OPEN_CLOSE: set(), KEY_STOP: set(), + KEY_POSITION: set()} + + @callback + def update_supported_features(self, entity_id, old_state, new_state, + update_state=True): + """Update dictionaries with supported features.""" + if not new_state: + for values in self._covers.values(): + values.discard(entity_id) + for values in self._tilts.values(): + values.discard(entity_id) + if update_state: + self.async_schedule_update_ha_state(True) + return + + features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + if features & (SUPPORT_OPEN | SUPPORT_CLOSE): + self._covers[KEY_OPEN_CLOSE].add(entity_id) + else: + self._covers[KEY_OPEN_CLOSE].discard(entity_id) + if features & (SUPPORT_STOP): + self._covers[KEY_STOP].add(entity_id) + else: + self._covers[KEY_STOP].discard(entity_id) + if features & (SUPPORT_SET_POSITION): + self._covers[KEY_POSITION].add(entity_id) + else: + self._covers[KEY_POSITION].discard(entity_id) + + if features & (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT): + self._tilts[KEY_OPEN_CLOSE].add(entity_id) + else: + self._tilts[KEY_OPEN_CLOSE].discard(entity_id) + if features & (SUPPORT_STOP_TILT): + self._tilts[KEY_STOP].add(entity_id) + else: + self._tilts[KEY_STOP].discard(entity_id) + if features & (SUPPORT_SET_TILT_POSITION): + self._tilts[KEY_POSITION].add(entity_id) + else: + self._tilts[KEY_POSITION].discard(entity_id) + + if update_state: + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register listeners.""" + for entity_id in self._entities: + new_state = self.hass.states.get(entity_id) + self.update_supported_features(entity_id, None, new_state, + update_state=False) + async_track_state_change(self.hass, self._entities, + self.update_supported_features) + await self.async_update() + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def assumed_state(self): + """Enable buttons even if at end position.""" + return self._assumed_state + + @property + def should_poll(self): + """Disable polling for cover group.""" + return False + + @property + def supported_features(self): + """Flag supported features for the cover.""" + return self._supported_features + + @property + def is_closed(self): + """Return if all covers in group are closed.""" + return self._is_closed + + @property + def current_cover_position(self): + """Return current position for all covers.""" + return self._cover_position + + @property + def current_cover_tilt_position(self): + """Return current tilt position for all covers.""" + return self._tilt_position + + async def async_open_cover(self, **kwargs): + """Move the covers up.""" + data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]} + await self.hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, data, blocking=True) + + async def async_close_cover(self, **kwargs): + """Move the covers down.""" + data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]} + await self.hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER, data, blocking=True) + + async def async_stop_cover(self, **kwargs): + """Fire the stop action.""" + data = {ATTR_ENTITY_ID: self._covers[KEY_STOP]} + await self.hass.services.async_call( + DOMAIN, SERVICE_STOP_COVER, data, blocking=True) + + async def async_set_cover_position(self, **kwargs): + """Set covers position.""" + data = {ATTR_ENTITY_ID: self._covers[KEY_POSITION], + ATTR_POSITION: kwargs[ATTR_POSITION]} + await self.hass.services.async_call( + DOMAIN, SERVICE_SET_COVER_POSITION, data, blocking=True) + + async def async_open_cover_tilt(self, **kwargs): + """Tilt covers open.""" + data = {ATTR_ENTITY_ID: self._tilts[KEY_OPEN_CLOSE]} + await self.hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER_TILT, data, blocking=True) + + async def async_close_cover_tilt(self, **kwargs): + """Tilt covers closed.""" + data = {ATTR_ENTITY_ID: self._tilts[KEY_OPEN_CLOSE]} + await self.hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER_TILT, data, blocking=True) + + async def async_stop_cover_tilt(self, **kwargs): + """Stop cover tilt.""" + data = {ATTR_ENTITY_ID: self._tilts[KEY_STOP]} + await self.hass.services.async_call( + DOMAIN, SERVICE_STOP_COVER_TILT, data, blocking=True) + + async def async_set_cover_tilt_position(self, **kwargs): + """Set tilt position.""" + data = {ATTR_ENTITY_ID: self._tilts[KEY_POSITION], + ATTR_TILT_POSITION: kwargs[ATTR_TILT_POSITION]} + await self.hass.services.async_call( + DOMAIN, SERVICE_SET_COVER_TILT_POSITION, data, blocking=True) + + async def async_update(self): + """Update state and attributes.""" + self._assumed_state = False + + self._is_closed = True + for entity_id in self._entities: + state = self.hass.states.get(entity_id) + if not state: + continue + if state.state != STATE_CLOSED: + self._is_closed = False + break + + self._cover_position = None + if self._covers[KEY_POSITION]: + position = -1 + self._cover_position = 0 if self.is_closed else 100 + for entity_id in self._covers[KEY_POSITION]: + state = self.hass.states.get(entity_id) + pos = state.attributes.get(ATTR_CURRENT_POSITION) + if position == -1: + position = pos + elif position != pos: + self._assumed_state = True + break + else: + if position != -1: + self._cover_position = position + + self._tilt_position = None + if self._tilts[KEY_POSITION]: + position = -1 + self._tilt_position = 100 + for entity_id in self._tilts[KEY_POSITION]: + state = self.hass.states.get(entity_id) + pos = state.attributes.get(ATTR_CURRENT_TILT_POSITION) + if position == -1: + position = pos + elif position != pos: + self._assumed_state = True + break + else: + if position != -1: + self._tilt_position = position + + supported_features = 0 + supported_features |= SUPPORT_OPEN | SUPPORT_CLOSE \ + if self._covers[KEY_OPEN_CLOSE] else 0 + supported_features |= SUPPORT_STOP \ + if self._covers[KEY_STOP] else 0 + supported_features |= SUPPORT_SET_POSITION \ + if self._covers[KEY_POSITION] else 0 + supported_features |= SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT \ + if self._tilts[KEY_OPEN_CLOSE] else 0 + supported_features |= SUPPORT_STOP_TILT \ + if self._tilts[KEY_STOP] else 0 + supported_features |= SUPPORT_SET_TILT_POSITION \ + if self._tilts[KEY_POSITION] else 0 + self._supported_features = supported_features + + if not self._assumed_state: + for entity_id in self._entities: + state = self.hass.states.get(entity_id) + if state and state.attributes.get(ATTR_ASSUMED_STATE): + self._assumed_state = True + break diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 0f31d3a9fe0..235ff5799cc 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -235,6 +235,11 @@ class MqttCover(MqttAvailability, CoverDevice): """No polling needed.""" return False + @property + def assumed_state(self): + """Return true if we do optimistic updates.""" + return self._optimistic + @property def name(self): """Return the name of the cover.""" diff --git a/homeassistant/components/cover/myq.py b/homeassistant/components/cover/myq.py index f07d3849fae..1e2ec43181c 100644 --- a/homeassistant/components/cover/myq.py +++ b/homeassistant/components/cover/myq.py @@ -69,6 +69,11 @@ class MyQDevice(CoverDevice): self._name = device['name'] self._status = STATE_CLOSED + @property + def device_class(self): + """Define this cover as a garage door.""" + return 'garage' + @property def should_poll(self): """Poll for state.""" diff --git a/homeassistant/components/cover/mysensors.py b/homeassistant/components/cover/mysensors.py index 391d2a22bda..3f8eb054710 100644 --- a/homeassistant/components/cover/mysensors.py +++ b/homeassistant/components/cover/mysensors.py @@ -9,10 +9,12 @@ from homeassistant.components.cover import ATTR_POSITION, DOMAIN, CoverDevice from homeassistant.const import STATE_OFF, STATE_ON -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for covers.""" +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the mysensors platform for covers.""" mysensors.setup_mysensors_platform( - hass, DOMAIN, discovery_info, MySensorsCover, add_devices=add_devices) + hass, DOMAIN, discovery_info, MySensorsCover, + async_add_devices=async_add_devices) class MySensorsCover(mysensors.MySensorsEntity, CoverDevice): @@ -40,7 +42,7 @@ class MySensorsCover(mysensors.MySensorsEntity, CoverDevice): set_req = self.gateway.const.SetReq return self._values.get(set_req.V_DIMMER) - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Move the cover up.""" set_req = self.gateway.const.SetReq self.gateway.set_child_value( @@ -51,9 +53,9 @@ class MySensorsCover(mysensors.MySensorsEntity, CoverDevice): self._values[set_req.V_DIMMER] = 100 else: self._values[set_req.V_LIGHT] = STATE_ON - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Move the cover down.""" set_req = self.gateway.const.SetReq self.gateway.set_child_value( @@ -64,9 +66,9 @@ class MySensorsCover(mysensors.MySensorsEntity, CoverDevice): self._values[set_req.V_DIMMER] = 0 else: self._values[set_req.V_LIGHT] = STATE_OFF - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" position = kwargs.get(ATTR_POSITION) set_req = self.gateway.const.SetReq @@ -75,9 +77,9 @@ class MySensorsCover(mysensors.MySensorsEntity, CoverDevice): if self.gateway.optimistic: # Optimistically assume that cover has changed state. self._values[set_req.V_DIMMER] = position - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the device.""" set_req = self.gateway.const.SetReq self.gateway.set_child_value( diff --git a/homeassistant/components/cover/opengarage.py b/homeassistant/components/cover/opengarage.py index d68021d7db3..028a7a0c9fc 100644 --- a/homeassistant/components/cover/opengarage.py +++ b/homeassistant/components/cover/opengarage.py @@ -18,30 +18,31 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -ATTR_DISTANCE_SENSOR = "distance_sensor" -ATTR_DOOR_STATE = "door_state" -ATTR_SIGNAL_STRENGTH = "wifi_signal" +ATTR_DISTANCE_SENSOR = 'distance_sensor' +ATTR_DOOR_STATE = 'door_state' +ATTR_SIGNAL_STRENGTH = 'wifi_signal' -CONF_DEVICEKEY = "device_key" +CONF_DEVICE_ID = 'device_id' +CONF_DEVICE_KEY = 'device_key' DEFAULT_NAME = 'OpenGarage' DEFAULT_PORT = 80 -STATE_CLOSING = "closing" -STATE_OFFLINE = "offline" -STATE_OPENING = "opening" -STATE_STOPPED = "stopped" +STATE_CLOSING = 'closing' +STATE_OFFLINE = 'offline' +STATE_OPENING = 'opening' +STATE_STOPPED = 'stopped' STATES_MAP = { 0: STATE_CLOSED, - 1: STATE_OPEN + 1: STATE_OPEN, } COVER_SCHEMA = vol.Schema({ - vol.Required(CONF_DEVICEKEY): cv.string, + vol.Required(CONF_DEVICE_KEY): cv.string, vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_NAME): cv.string }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -50,7 +51,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up OpenGarage covers.""" + """Set up the OpenGarage covers.""" covers = [] devices = config.get(CONF_COVERS) @@ -59,8 +60,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): CONF_NAME: device_config.get(CONF_NAME), CONF_HOST: device_config.get(CONF_HOST), CONF_PORT: device_config.get(CONF_PORT), - "device_id": device_config.get(CONF_DEVICE, device_id), - CONF_DEVICEKEY: device_config.get(CONF_DEVICEKEY) + CONF_DEVICE_ID: device_config.get(CONF_DEVICE, device_id), + CONF_DEVICE_KEY: device_config.get(CONF_DEVICE_KEY) } covers.append(OpenGarageCover(hass, args)) @@ -79,8 +80,8 @@ class OpenGarageCover(CoverDevice): self.hass = hass self._name = args[CONF_NAME] self.device_id = args['device_id'] - self._devicekey = args[CONF_DEVICEKEY] - self._state = STATE_UNKNOWN + self._device_key = args[CONF_DEVICE_KEY] + self._state = None self._state_before_move = None self.dist = None self.signal = None @@ -138,8 +139,8 @@ class OpenGarageCover(CoverDevice): try: status = self._get_status() if self._name is None: - if status["name"] is not None: - self._name = status["name"] + if status['name'] is not None: + self._name = status['name'] state = STATES_MAP.get(status.get('door'), STATE_UNKNOWN) if self._state_before_move is not None: if self._state_before_move != state: @@ -152,7 +153,7 @@ class OpenGarageCover(CoverDevice): self.signal = status.get('rssi') self.dist = status.get('dist') self._available = True - except (requests.exceptions.RequestException) as ex: + except requests.exceptions.RequestException as ex: _LOGGER.error("Unable to connect to OpenGarage device: %(reason)s", dict(reason=ex)) self._state = STATE_OFFLINE @@ -166,15 +167,15 @@ class OpenGarageCover(CoverDevice): def _push_button(self): """Send commands to API.""" url = '{}/cc?dkey={}&click=1'.format( - self.opengarage_url, self._devicekey) + self.opengarage_url, self._device_key) try: response = requests.get(url, timeout=10).json() - if response["result"] == 2: - _LOGGER.error("Unable to control %s: device_key is incorrect.", + if response['result'] == 2: + _LOGGER.error("Unable to control %s: Device key is incorrect", self._name) self._state = self._state_before_move self._state_before_move = None - except (requests.exceptions.RequestException) as ex: + except requests.exceptions.RequestException as ex: _LOGGER.error("Unable to connect to OpenGarage device: %(reason)s", dict(reason=ex)) self._state = self._state_before_move diff --git a/homeassistant/components/cover/rpi_gpio.py b/homeassistant/components/cover/rpi_gpio.py index 77cd0b0f7e2..49666139330 100644 --- a/homeassistant/components/cover/rpi_gpio.py +++ b/homeassistant/components/cover/rpi_gpio.py @@ -87,7 +87,7 @@ class RPiGPIOCover(CoverDevice): self._invert_relay = invert_relay rpi_gpio.setup_output(self._relay_pin) rpi_gpio.setup_input(self._state_pin, self._state_pull_mode) - rpi_gpio.write_output(self._relay_pin, not self._invert_relay) + rpi_gpio.write_output(self._relay_pin, 0 if self._invert_relay else 1) @property def name(self): @@ -105,9 +105,9 @@ class RPiGPIOCover(CoverDevice): def _trigger(self): """Trigger the cover.""" - rpi_gpio.write_output(self._relay_pin, self._invert_relay) + rpi_gpio.write_output(self._relay_pin, 1 if self._invert_relay else 0) sleep(self._relay_time) - rpi_gpio.write_output(self._relay_pin, not self._invert_relay) + rpi_gpio.write_output(self._relay_pin, 0 if self._invert_relay else 1) def close_cover(self, **kwargs): """Close the cover.""" diff --git a/homeassistant/components/cover/ryobi_gdo.py b/homeassistant/components/cover/ryobi_gdo.py new file mode 100644 index 00000000000..a11d70dd3ad --- /dev/null +++ b/homeassistant/components/cover/ryobi_gdo.py @@ -0,0 +1,103 @@ +""" +Ryobi platform for the cover component. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/cover.ryobi_gdo/ +""" +import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +from homeassistant.components.cover import ( + CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, STATE_UNKNOWN, STATE_CLOSED) + +REQUIREMENTS = ['py_ryobi_gdo==0.0.10'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DEVICE_ID = 'device_id' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, +}) + +SUPPORTED_FEATURES = (SUPPORT_OPEN | SUPPORT_CLOSE) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Ryobi covers.""" + from py_ryobi_gdo import RyobiGDO as ryobi_door + covers = [] + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + devices = config.get(CONF_DEVICE_ID) + + for device_id in devices: + my_door = ryobi_door(username, password, device_id) + _LOGGER.debug("Getting the API key") + if my_door.get_api_key() is False: + _LOGGER.error("Wrong credentials, no API key retrieved") + return + _LOGGER.debug("Checking if the device ID is present") + if my_door.check_device_id() is False: + _LOGGER.error("%s not in your device list", device_id) + return + _LOGGER.debug("Adding device %s to covers", device_id) + covers.append(RyobiCover(hass, my_door)) + if covers: + _LOGGER.debug("Adding covers") + add_devices(covers, True) + + +class RyobiCover(CoverDevice): + """Representation of a ryobi cover.""" + + def __init__(self, hass, ryobi_door): + """Initialize the cover.""" + self.ryobi_door = ryobi_door + self._name = 'ryobi_gdo_{}'.format(ryobi_door.get_device_id()) + self._door_state = None + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self._door_state == STATE_UNKNOWN: + return False + return self._door_state == STATE_CLOSED + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'garage' + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORTED_FEATURES + + def close_cover(self, **kwargs): + """Close the cover.""" + _LOGGER.debug("Closing garage door") + self.ryobi_door.close_device() + + def open_cover(self, **kwargs): + """Open the cover.""" + _LOGGER.debug("Opening garage door") + self.ryobi_door.open_device() + + def update(self): + """Update status from the door.""" + _LOGGER.debug("Updating RyobiGDO status") + self.ryobi_door.update() + self._door_state = self.ryobi_door.get_door_status() diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index 6fb8e92e051..cf8b7dfad48 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up Tahoma covers.""" + """Set up the Tahoma covers.""" controller = hass.data[TAHOMA_DOMAIN]['controller'] devices = [] for device in hass.data[TAHOMA_DOMAIN]['devices']['cover']: @@ -79,5 +79,9 @@ class TahomaCover(TahomaDevice, CoverDevice): if self.tahoma_device.type == \ 'io:RollerShutterWithLowSpeedManagementIOComponent': self.apply_action('setPosition', 'secured') + elif self.tahoma_device.type in \ + ('rts:BlindRTSComponent', + 'io:ExteriorVenetianBlindIOComponent'): + self.apply_action('my') else: self.apply_action('stopIdentify') diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index f4728a12a3b..4e197365a70 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -234,7 +234,9 @@ class CoverTemplate(CoverDevice): None is unknown, 0 is closed, 100 is fully open. """ - return self._position + if self._position_template or self._position_script: + return self._position + return None @property def current_cover_tilt_position(self): diff --git a/homeassistant/components/deconz/.translations/bg.json b/homeassistant/components/deconz/.translations/bg.json new file mode 100644 index 00000000000..91727cae257 --- /dev/null +++ b/homeassistant/components/deconz/.translations/bg.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "no_bridges": "\u041d\u0435 \u0441\u0430 \u043e\u0442\u043a\u0440\u0438\u0442\u0438 \u043c\u043e\u0441\u0442\u043e\u0432\u0435 deCONZ", + "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u043e \u043a\u043e\u043f\u0438\u0435 \u043d\u0430 deCONZ" + }, + "error": { + "no_key": "\u041d\u0435 \u043c\u043e\u0436\u0430 \u0434\u0430 \u0441\u0435 \u043f\u043e\u043b\u0443\u0447\u0438 API \u043a\u043b\u044e\u0447" + }, + "step": { + "init": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442 (\u0441\u0442\u043e\u0439\u043d\u043e\u0441\u0442 \u043f\u043e \u043f\u043e\u0434\u0440\u0430\u0437\u0431\u0438\u0440\u0430\u043d\u0435: '80')" + }, + "title": "\u0414\u0435\u0444\u0438\u043d\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 deCONZ \u0448\u043b\u044e\u0437" + }, + "link": { + "description": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438 deCONZ \u0448\u043b\u044e\u0437\u0430 \u0437\u0430 \u0434\u0430 \u0441\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430 \u0441 Home Assistant.\n\n1. \u041e\u0442\u0432\u043e\u0440\u0435\u0442\u0435 \u0441\u0438\u0441\u0442\u0435\u043c\u043d\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0430 deCONZ\n2. \u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 \"Unlock Gateway\"", + "title": "\u0412\u0440\u044a\u0437\u043a\u0430 \u0441 deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/cy.json b/homeassistant/components/deconz/.translations/cy.json new file mode 100644 index 00000000000..fff54bb3f6c --- /dev/null +++ b/homeassistant/components/deconz/.translations/cy.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Pont eisoes wedi'i ffurfweddu", + "no_bridges": "Dim pontydd deCONZ wedi eu darganfod", + "one_instance_only": "Elfen dim ond yn cefnogi enghraifft deCONZ" + }, + "error": { + "no_key": "Methu cael allwedd API" + }, + "step": { + "init": { + "data": { + "host": "Gwesteiwr", + "port": "Port (gwerth diofyn: '80')" + }, + "title": "Diffiniwch porth dad-adeiladu" + }, + "link": { + "description": "Datgloi eich porth deCONZ i gofrestru gyda Cynorthwydd Cartref.\n\n1. Ewch i osodiadau system deCONZ \n2. Bwyso botwm \"Datgloi porth\"", + "title": "Cysylltu \u00e2 deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/da.json b/homeassistant/components/deconz/.translations/da.json new file mode 100644 index 00000000000..698f55c59ec --- /dev/null +++ b/homeassistant/components/deconz/.translations/da.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "init": { + "data": { + "host": "V\u00e6rt" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json new file mode 100644 index 00000000000..9d3dc9e6e62 --- /dev/null +++ b/homeassistant/components/deconz/.translations/de.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge ist bereits konfiguriert", + "no_bridges": "Keine deCON-Bridges entdeckt", + "one_instance_only": "Komponente unterst\u00fctzt nur eine deCONZ-Instanz" + }, + "error": { + "no_key": "Es konnte kein API-Schl\u00fcssel abgerufen werden" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port (Standartwert : '80')" + }, + "title": "Definieren Sie den deCONZ-Gateway" + }, + "link": { + "description": "Entsperren Sie Ihr deCONZ-Gateway, um sich bei Home Assistant zu registrieren. \n\n 1. Gehen Sie zu den deCONZ-Systemeinstellungen \n 2. Dr\u00fccken Sie die Taste \"Gateway entsperren\"", + "title": "Mit deCONZ verbinden" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json new file mode 100644 index 00000000000..a2f90e49e3a --- /dev/null +++ b/homeassistant/components/deconz/.translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge is already configured", + "no_bridges": "No deCONZ bridges discovered", + "one_instance_only": "Component only supports one deCONZ instance" + }, + "error": { + "no_key": "Couldn't get an API key" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port (default value: '80')" + }, + "title": "Define deCONZ gateway" + }, + "link": { + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ system settings\n2. Press \"Unlock Gateway\" button", + "title": "Link with deCONZ" + }, + "options": { + "title": "Extra configuration options for deCONZ", + "data": { + "allow_clip_sensor": "Allow importing virtual sensors" + } + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/hu.json b/homeassistant/components/deconz/.translations/hu.json new file mode 100644 index 00000000000..42aab9c6d7e --- /dev/null +++ b/homeassistant/components/deconz/.translations/hu.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "one_instance_only": "Ez a komponens csak egy deCONZ egys\u00e9get t\u00e1mogat" + }, + "error": { + "no_key": "API kulcs lek\u00e9r\u00e9se nem siker\u00fclt" + }, + "step": { + "init": { + "data": { + "host": "H\u00e1zigazda (Host)", + "port": "Port (alap\u00e9rtelmezett \u00e9rt\u00e9k: '80')" + } + }, + "link": { + "title": "Kapcsol\u00f3d\u00e1s a deCONZ-hoz" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ko.json b/homeassistant/components/deconz/.translations/ko.json new file mode 100644 index 00000000000..d6de1028218 --- /dev/null +++ b/homeassistant/components/deconz/.translations/ko.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "no_bridges": "\ubc1c\uacac\ub41c deCONZ \ube0c\ub9bf\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", + "one_instance_only": "\uad6c\uc131\uc694\uc18c\ub294 \ud558\ub098\uc758 deCONZ \uc778\uc2a4\ud134\uc2a4 \ub9cc \uc9c0\uc6d0\ud569\ub2c8\ub2e4" + }, + "error": { + "no_key": "API \ud0a4\ub97c \uac00\uc838\uc62c \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + }, + "step": { + "init": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8 (\uae30\ubcf8\uac12: '80')" + }, + "title": "deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774 \uc815\uc758" + }, + "link": { + "description": "deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774\ub97c \uc5b8\ub77d\ud558\uc5ec Home Assistant \uc5d0 \uc5f0\uacb0\ud558\uae30\n\n1. deCONZ \uc2dc\uc2a4\ud15c \uc124\uc815\uc73c\ub85c \uc774\ub3d9\ud558\uc138\uc694\n2. \"Unlock Gateway\" \ubc84\ud2bc\uc744 \ub204\ub974\uc138\uc694 ", + "title": "deCONZ \uc640 \uc5f0\uacb0" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json new file mode 100644 index 00000000000..2a9dfc5e543 --- /dev/null +++ b/homeassistant/components/deconz/.translations/lb.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge ass schon konfigur\u00e9iert", + "no_bridges": "Keng dECONZ bridges fonnt", + "one_instance_only": "Komponent \u00ebnnerst\u00ebtzt n\u00ebmmen eng deCONZ Instanz" + }, + "error": { + "no_key": "Konnt keen API Schl\u00ebssel kr\u00e9ien" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port (Standard Wert: '80')" + }, + "title": "deCONZ gateway d\u00e9fin\u00e9ieren" + }, + "link": { + "description": "Entsperrt \u00e4r deCONZ gateway fir se mat Home Assistant ze registr\u00e9ieren.\n\n1. Gidd op\u00a0deCONZ System Astellungen\n2. Dr\u00e9ckt \"Unlock\" Gateway Kn\u00e4ppchen", + "title": "Link mat deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json new file mode 100644 index 00000000000..90d13bb39b4 --- /dev/null +++ b/homeassistant/components/deconz/.translations/nl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge is al geconfigureerd", + "no_bridges": "Geen deCONZ bruggen ontdekt", + "one_instance_only": "Component ondersteunt slechts \u00e9\u00e9n deCONZ instance" + }, + "error": { + "no_key": "Kon geen API-sleutel ophalen" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Poort (standaard: '80')" + }, + "title": "Definieer deCONZ gateway" + }, + "link": { + "description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen\n2. Druk op de knop \"Gateway ontgrendelen\"", + "title": "Koppel met deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json new file mode 100644 index 00000000000..25e3b0b7d68 --- /dev/null +++ b/homeassistant/components/deconz/.translations/no.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Broen er allerede konfigurert", + "no_bridges": "Ingen deCONZ broer oppdaget", + "one_instance_only": "Komponenten st\u00f8tter bare \u00e9n deCONZ forekomst" + }, + "error": { + "no_key": "Kunne ikke f\u00e5 en API-n\u00f8kkel" + }, + "step": { + "init": { + "data": { + "host": "Vert", + "port": "Port (standardverdi: '80')" + }, + "title": "Definer deCONZ-gatewayen" + }, + "link": { + "description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere deg med Home Assistant. \n\n 1. G\u00e5 til deCONZ-systeminnstillinger \n 2. Trykk p\u00e5 \"L\u00e5s opp gateway\" knappen", + "title": "Koble til deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json new file mode 100644 index 00000000000..bb7488fcbec --- /dev/null +++ b/homeassistant/components/deconz/.translations/pl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Mostek jest ju\u017c skonfigurowany", + "no_bridges": "Nie odkryto mostk\u00f3w deCONZ", + "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 deCONZ" + }, + "error": { + "no_key": "Nie mo\u017cna uzyska\u0107 klucza API" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Port (warto\u015b\u0107 domy\u015blna: \"80\")" + }, + "title": "Zdefiniuj bramk\u0119 deCONZ" + }, + "link": { + "description": "Odblokuj bramk\u0119 deCONZ, aby zarejestrowa\u0107 j\u0105 w Home Assistant. \n\n 1. Przejd\u017a do ustawie\u0144 systemu deCONZ \n 2. Naci\u015bnij przycisk \"Odblokuj bramk\u0119\"", + "title": "Po\u0142\u0105cz z deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/pt.json b/homeassistant/components/deconz/.translations/pt.json new file mode 100644 index 00000000000..2a00c698691 --- /dev/null +++ b/homeassistant/components/deconz/.translations/pt.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge j\u00e1 est\u00e1 configurada" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json new file mode 100644 index 00000000000..b0dc6a8a4a8 --- /dev/null +++ b/homeassistant/components/deconz/.translations/ru.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u0428\u043b\u044e\u0437 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d", + "no_bridges": "\u0428\u043b\u044e\u0437\u044b deCONZ \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b", + "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d \u044d\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440 deCONZ" + }, + "error": { + "no_key": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u043b\u044e\u0447 API" + }, + "step": { + "init": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442 (\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: '80')" + }, + "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0448\u043b\u044e\u0437 deCONZ" + }, + "link": { + "description": "\u0420\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0439\u0442\u0435 \u0448\u043b\u044e\u0437 deCONZ \u0434\u043b\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0432 Home Assistant:\n\n1. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043a \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u043c \u0441\u0438\u0441\u0442\u0435\u043c\u044b deCONZ\n2. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u00ab\u0420\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0448\u043b\u044e\u0437\u00bb", + "title": "\u0421\u0432\u044f\u0437\u044c \u0441 deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/sl.json b/homeassistant/components/deconz/.translations/sl.json new file mode 100644 index 00000000000..b738002b273 --- /dev/null +++ b/homeassistant/components/deconz/.translations/sl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Most je \u017ee nastavljen", + "no_bridges": "Ni odkritih mostov deCONZ", + "one_instance_only": "Komponenta podpira le en primerek deCONZ" + }, + "error": { + "no_key": "Klju\u010da API ni mogo\u010de dobiti" + }, + "step": { + "init": { + "data": { + "host": "Gostitelj", + "port": "Vrata (privzeta vrednost: '80')" + }, + "title": "Dolo\u010dite deCONZ prehod" + }, + "link": { + "description": "Odklenite va\u0161 deCONZ gateway za registracijo z Home Assistant-om. \n1. Pojdite v deCONT sistemske nastavitve\n2. Pritisnite tipko \"odkleni prehod\"", + "title": "Povezava z deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/zh-Hans.json b/homeassistant/components/deconz/.translations/zh-Hans.json new file mode 100644 index 00000000000..f41b5b5111c --- /dev/null +++ b/homeassistant/components/deconz/.translations/zh-Hans.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u6865\u63a5\u5668\u5df2\u914d\u7f6e\u5b8c\u6210", + "no_bridges": "\u6ca1\u6709\u53d1\u73b0 deCONZ \u7684\u6865\u63a5\u8bbe\u5907", + "one_instance_only": "\u7ec4\u4ef6\u53ea\u652f\u6301\u4e00\u4e2a deCONZ \u5b9e\u4f8b" + }, + "error": { + "no_key": "\u65e0\u6cd5\u83b7\u53d6 API \u5bc6\u94a5" + }, + "step": { + "init": { + "data": { + "host": "\u4e3b\u673a", + "port": "\u7aef\u53e3\uff08\u9ed8\u8ba4\u503c\uff1a'80'\uff09" + }, + "title": "\u5b9a\u4e49 deCONZ \u7f51\u5173" + }, + "link": { + "description": "\u89e3\u9501\u60a8\u7684 deCONZ \u7f51\u5173\u4ee5\u6ce8\u518c\u5230 Home Assistant\u3002 \n\n 1. \u524d\u5f80 deCONZ \u7cfb\u7edf\u8bbe\u7f6e\n 2. \u70b9\u51fb\u201c\u89e3\u9501\u7f51\u5173\u201d\u6309\u94ae", + "title": "\u8fde\u63a5 deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json new file mode 100644 index 00000000000..33be3846eb8 --- /dev/null +++ b/homeassistant/components/deconz/.translations/zh-Hant.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "no_bridges": "\u672a\u641c\u5c0b\u5230 deCONZ Bridfe", + "one_instance_only": "\u7d44\u4ef6\u50c5\u652f\u63f4\u4e00\u7d44 deCONZ \u5be6\u4f8b" + }, + "error": { + "no_key": "\u7121\u6cd5\u53d6\u5f97 API key" + }, + "step": { + "init": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0\uff08\u9810\u8a2d\u503c\uff1a'80'\uff09" + }, + "title": "\u5b9a\u7fa9 deCONZ \u7db2\u95dc" + }, + "link": { + "description": "\u89e3\u9664 deCONZ \u7db2\u95dc\u9396\u5b9a\uff0c\u4ee5\u65bc Home Assistant \u9032\u884c\u8a3b\u518a\u3002\n\n1. \u9032\u5165 deCONZ \u7cfb\u7d71\u8a2d\u5b9a\n2. \u6309\u4e0b\u300c\u89e3\u9664\u7db2\u95dc\u9396\u5b9a\uff08Unlock Gateway\uff09\u300d\u6309\u9215", + "title": "\u9023\u7d50\u81f3 deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index de6d3e89859..850645225d0 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -4,27 +4,25 @@ Support for deCONZ devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/deconz/ """ -import logging - import voluptuous as vol -from homeassistant.components.discovery import SERVICE_DECONZ from homeassistant.const import ( - CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) -from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import discovery -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util.json import load_json, save_json + CONF_API_KEY, CONF_EVENT, CONF_HOST, + CONF_ID, CONF_PORT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import EventOrigin, callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) +from homeassistant.util import slugify +from homeassistant.util.json import load_json -REQUIREMENTS = ['pydeconz==31'] +# Loading the config flow file will register the flow +from .config_flow import configured_hosts +from .const import ( + CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DATA_DECONZ_EVENT, + DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DOMAIN, _LOGGER) -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'deconz' -DATA_DECONZ_ID = 'deconz_entities' - -CONFIG_FILE = 'deconz.conf' +REQUIREMENTS = ['pydeconz==38'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -34,6 +32,8 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) +SERVICE_DECONZ = 'configure' + SERVICE_FIELD = 'field' SERVICE_ENTITY = 'entity' SERVICE_DATA = 'data' @@ -45,56 +45,47 @@ SERVICE_SCHEMA = vol.Schema({ }) -CONFIG_INSTRUCTIONS = """ -Unlock your deCONZ gateway to register with Home Assistant. - -1. [Go to deCONZ system settings](http://{}:{}/edit_system.html) -2. Press "Unlock Gateway" button - -[deCONZ platform documentation](https://home-assistant.io/components/deconz/) -""" - - async def async_setup(hass, config): - """Set up services and configuration for deCONZ component.""" - result = False - config_file = await hass.async_add_job( - load_json, hass.config.path(CONFIG_FILE)) - - async def async_deconz_discovered(service, discovery_info): - """Call when deCONZ gateway has been found.""" - deconz_config = {} - deconz_config[CONF_HOST] = discovery_info.get(CONF_HOST) - deconz_config[CONF_PORT] = discovery_info.get(CONF_PORT) - await async_request_configuration(hass, config, deconz_config) - - if config_file: - result = await async_setup_deconz(hass, config, config_file) - - if not result and DOMAIN in config and CONF_HOST in config[DOMAIN]: - deconz_config = config[DOMAIN] - if CONF_API_KEY in deconz_config: - result = await async_setup_deconz(hass, config, deconz_config) - else: - await async_request_configuration(hass, config, deconz_config) - return True - - if not result: - discovery.async_listen(hass, SERVICE_DECONZ, async_deconz_discovered) + """Load configuration for deCONZ component. + Discovery has loaded the component if DOMAIN is not present in config. + """ + if DOMAIN in config: + deconz_config = None + config_file = await hass.async_add_job( + load_json, hass.config.path(CONFIG_FILE)) + if config_file: + deconz_config = config_file + elif CONF_HOST in config[DOMAIN]: + deconz_config = config[DOMAIN] + if deconz_config and not configured_hosts(hass): + hass.async_add_job(hass.config_entries.flow.async_init( + DOMAIN, source='import', data=deconz_config + )) return True -async def async_setup_deconz(hass, config, deconz_config): - """Set up a deCONZ session. +async def async_setup_entry(hass, config_entry): + """Set up a deCONZ bridge for a config entry. Load config, group, light and sensor data for server information. Start websocket for push notification of state changes from deCONZ. """ - _LOGGER.debug("deCONZ config %s", deconz_config) from pydeconz import DeconzSession - websession = async_get_clientsession(hass) - deconz = DeconzSession(hass.loop, websession, **deconz_config) + if DOMAIN in hass.data: + _LOGGER.error( + "Config entry failed since one deCONZ instance already exists") + return False + + @callback + def async_add_device_callback(device_type, device): + """Called when a new device has been created in deCONZ.""" + async_dispatcher_send( + hass, 'deconz_new_{}'.format(device_type), [device]) + + session = aiohttp_client.async_get_clientsession(hass) + deconz = DeconzSession(hass.loop, session, **config_entry.data, + async_add_device=async_add_device_callback) result = await deconz.async_load_parameters() if result is False: _LOGGER.error("Failed to communicate with deCONZ") @@ -102,10 +93,27 @@ async def async_setup_deconz(hass, config, deconz_config): hass.data[DOMAIN] = deconz hass.data[DATA_DECONZ_ID] = {} + hass.data[DATA_DECONZ_EVENT] = [] + hass.data[DATA_DECONZ_UNSUB] = [] for component in ['binary_sensor', 'light', 'scene', 'sensor']: - hass.async_add_job(discovery.async_load_platform( - hass, component, DOMAIN, {}, config)) + hass.async_add_job(hass.config_entries.async_forward_entry_setup( + config_entry, component)) + + @callback + def async_add_remote(sensors): + """Setup remote from deCONZ.""" + from pydeconz.sensor import SWITCH as DECONZ_REMOTE + allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) + for sensor in sensors: + if sensor.type in DECONZ_REMOTE and \ + not (not allow_clip_sensor and sensor.type.startswith('CLIP')): + hass.data[DATA_DECONZ_EVENT].append(DeconzEvent(hass, sensor)) + hass.data[DATA_DECONZ_UNSUB].append( + async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_remote)) + + async_add_remote(deconz.sensors.values()) + deconz.start() async def async_configure(call): @@ -136,7 +144,7 @@ async def async_setup_deconz(hass, config, deconz_config): return await deconz.async_put_state(field, data) hass.services.async_register( - DOMAIN, 'configure', async_configure, schema=SERVICE_SCHEMA) + DOMAIN, SERVICE_DECONZ, async_configure, schema=SERVICE_SCHEMA) @callback def deconz_shutdown(event): @@ -153,36 +161,41 @@ async def async_setup_deconz(hass, config, deconz_config): return True -async def async_request_configuration(hass, config, deconz_config): - """Request configuration steps from the user.""" - configurator = hass.components.configurator +async def async_unload_entry(hass, config_entry): + """Unload deCONZ config entry.""" + deconz = hass.data.pop(DOMAIN) + hass.services.async_remove(DOMAIN, SERVICE_DECONZ) + deconz.close() + for component in ['binary_sensor', 'light', 'scene', 'sensor']: + await hass.config_entries.async_forward_entry_unload( + config_entry, component) + dispatchers = hass.data[DATA_DECONZ_UNSUB] + for unsub_dispatcher in dispatchers: + unsub_dispatcher() + hass.data[DATA_DECONZ_UNSUB] = [] + hass.data[DATA_DECONZ_EVENT] = [] + hass.data[DATA_DECONZ_ID] = [] + return True - async def async_configuration_callback(data): - """Set up actions to do when our configuration callback is called.""" - from pydeconz.utils import async_get_api_key - api_key = await async_get_api_key(hass.loop, **deconz_config) - if api_key: - deconz_config[CONF_API_KEY] = api_key - result = await async_setup_deconz(hass, config, deconz_config) - if result: - await hass.async_add_job( - save_json, hass.config.path(CONFIG_FILE), deconz_config) - configurator.async_request_done(request_id) - return - else: - configurator.async_notify_errors( - request_id, "Couldn't load configuration.") - else: - configurator.async_notify_errors( - request_id, "Couldn't get an API key.") - return - instructions = CONFIG_INSTRUCTIONS.format( - deconz_config[CONF_HOST], deconz_config[CONF_PORT]) +class DeconzEvent(object): + """When you want signals instead of entities. - request_id = configurator.async_request_config( - "deCONZ", async_configuration_callback, - description=instructions, - entity_picture="/static/images/logo_deconz.jpeg", - submit_caption="I have unlocked the gateway", - ) + Stateless sensors such as remotes are expected to generate an event + instead of a sensor entity in hass. + """ + + def __init__(self, hass, device): + """Register callback that will be used for signals.""" + self._hass = hass + self._device = device + self._device.register_async_callback(self.async_update_callback) + self._event = 'deconz_{}'.format(CONF_EVENT) + self._id = slugify(self._device.name) + + @callback + def async_update_callback(self, reason): + """Fire the event if reason is that state is updated.""" + if reason['state']: + data = {CONF_ID: self._id, CONF_EVENT: self._device.state} + self._hass.bus.async_fire(self._event, data, EventOrigin.remote) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py new file mode 100644 index 00000000000..cb7c3aad7fd --- /dev/null +++ b/homeassistant/components/deconz/config_flow.py @@ -0,0 +1,164 @@ +"""Config flow to configure deCONZ component.""" + +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.core import callback +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +from homeassistant.helpers import aiohttp_client +from homeassistant.util.json import load_json + +from .const import CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DOMAIN + +CONF_BRIDGEID = 'bridgeid' + + +@callback +def configured_hosts(hass): + """Return a set of the configured hosts.""" + return set(entry.data[CONF_HOST] for entry + in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class DeconzFlowHandler(data_entry_flow.FlowHandler): + """Handle a deCONZ config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize the deCONZ config flow.""" + self.bridges = [] + self.deconz_config = {} + + async def async_step_init(self, user_input=None): + """Handle a deCONZ config flow start. + + Only allows one instance to be set up. + If only one bridge is found go to link step. + If more than one bridge is found let user choose bridge to link. + """ + from pydeconz.utils import async_discovery + + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + + if user_input is not None: + for bridge in self.bridges: + if bridge[CONF_HOST] == user_input[CONF_HOST]: + self.deconz_config = bridge + return await self.async_step_link() + + session = aiohttp_client.async_get_clientsession(self.hass) + self.bridges = await async_discovery(session) + + if len(self.bridges) == 1: + self.deconz_config = self.bridges[0] + return await self.async_step_link() + elif len(self.bridges) > 1: + hosts = [] + for bridge in self.bridges: + hosts.append(bridge[CONF_HOST]) + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({ + vol.Required(CONF_HOST): vol.In(hosts) + }) + ) + + return self.async_abort( + reason='no_bridges' + ) + + async def async_step_link(self, user_input=None): + """Attempt to link with the deCONZ bridge.""" + from pydeconz.utils import async_get_api_key + errors = {} + + if user_input is not None: + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + session = aiohttp_client.async_get_clientsession(self.hass) + api_key = await async_get_api_key(session, **self.deconz_config) + if api_key: + self.deconz_config[CONF_API_KEY] = api_key + return await self.async_step_options() + errors['base'] = 'no_key' + + return self.async_show_form( + step_id='link', + errors=errors, + ) + + async def async_step_options(self, user_input=None): + """Extra options for deCONZ. + + CONF_CLIP_SENSOR -- Allow user to choose if they want clip sensors. + """ + from pydeconz.utils import async_get_bridgeid + + if user_input is not None: + self.deconz_config[CONF_ALLOW_CLIP_SENSOR] = \ + user_input[CONF_ALLOW_CLIP_SENSOR] + + if CONF_BRIDGEID not in self.deconz_config: + session = aiohttp_client.async_get_clientsession(self.hass) + self.deconz_config[CONF_BRIDGEID] = await async_get_bridgeid( + session, **self.deconz_config) + + return self.async_create_entry( + title='deCONZ-' + self.deconz_config[CONF_BRIDGEID], + data=self.deconz_config + ) + + return self.async_show_form( + step_id='options', + data_schema=vol.Schema({ + vol.Optional(CONF_ALLOW_CLIP_SENSOR): bool, + }), + ) + + async def async_step_discovery(self, discovery_info): + """Prepare configuration for a discovered deCONZ bridge. + + This flow is triggered by the discovery component. + """ + deconz_config = {} + deconz_config[CONF_HOST] = discovery_info.get(CONF_HOST) + deconz_config[CONF_PORT] = discovery_info.get(CONF_PORT) + deconz_config[CONF_BRIDGEID] = discovery_info.get('serial') + + config_file = await self.hass.async_add_job( + load_json, self.hass.config.path(CONFIG_FILE)) + if config_file and \ + config_file[CONF_HOST] == deconz_config[CONF_HOST] and \ + CONF_API_KEY in config_file: + deconz_config[CONF_API_KEY] = config_file[CONF_API_KEY] + + return await self.async_step_import(deconz_config) + + async def async_step_import(self, import_config): + """Import a deCONZ bridge as a config entry. + + This flow is triggered by `async_setup` for configured bridges. + This flow is also triggered by `async_step_discovery`. + + This will execute for any bridge that does not have a + config entry yet (based on host). + + If an API key is provided, we will create an entry. + Otherwise we will delegate to `link` step which + will ask user to link the bridge. + """ + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + + self.deconz_config = import_config + if CONF_API_KEY not in import_config: + return await self.async_step_link() + + self.deconz_config[CONF_ALLOW_CLIP_SENSOR] = True + return self.async_create_entry( + title='deCONZ-' + self.deconz_config[CONF_BRIDGEID], + data=self.deconz_config + ) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py new file mode 100644 index 00000000000..43f3c6441da --- /dev/null +++ b/homeassistant/components/deconz/const.py @@ -0,0 +1,12 @@ +"""Constants for the deCONZ component.""" +import logging + +_LOGGER = logging.getLogger('homeassistant.components.deconz') + +DOMAIN = 'deconz' +CONFIG_FILE = 'deconz.conf' +DATA_DECONZ_EVENT = 'deconz_events' +DATA_DECONZ_ID = 'deconz_entities' +DATA_DECONZ_UNSUB = 'deconz_dispatchers' + +CONF_ALLOW_CLIP_SENSOR = 'allow_clip_sensor' diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json new file mode 100644 index 00000000000..cabe58694d2 --- /dev/null +++ b/homeassistant/components/deconz/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "title": "deCONZ Zigbee gateway", + "step": { + "init": { + "title": "Define deCONZ gateway", + "data": { + "host": "Host", + "port": "Port (default value: '80')" + } + }, + "link": { + "title": "Link with deCONZ", + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ system settings\n2. Press \"Unlock Gateway\" button" + }, + "options": { + "title": "Extra configuration options for deCONZ", + "data":{ + "allow_clip_sensor": "Allow importing virtual sensors" + } + } + }, + "error": { + "no_key": "Couldn't get an API key" + }, + "abort": { + "already_configured": "Bridge is already configured", + "no_bridges": "No deCONZ bridges discovered", + "one_instance_only": "Component only supports one deCONZ instance" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_sun_light_trigger.py b/homeassistant/components/device_sun_light_trigger.py index a1297c5c118..641ade7308b 100644 --- a/homeassistant/components/device_sun_light_trigger.py +++ b/homeassistant/components/device_sun_light_trigger.py @@ -16,7 +16,6 @@ from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change) from homeassistant.helpers.sun import is_up, get_astral_event_next -from homeassistant.loader import get_component import homeassistant.helpers.config_validation as cv DOMAIN = 'device_sun_light_trigger' @@ -48,9 +47,9 @@ CONFIG_SCHEMA = vol.Schema({ def async_setup(hass, config): """Set up the triggers to control lights based on device presence.""" logger = logging.getLogger(__name__) - device_tracker = get_component('device_tracker') - group = get_component('group') - light = get_component('light') + device_tracker = hass.components.device_tracker + group = hass.components.group + light = hass.components.light conf = config[DOMAIN] disable_turn_off = conf.get(CONF_DISABLE_TURN_OFF) light_group = conf.get(CONF_LIGHT_GROUP, light.ENTITY_ID_ALL_LIGHTS) @@ -58,14 +57,14 @@ def async_setup(hass, config): device_group = conf.get( CONF_DEVICE_GROUP, device_tracker.ENTITY_ID_ALL_DEVICES) device_entity_ids = group.get_entity_ids( - hass, device_group, device_tracker.DOMAIN) + device_group, device_tracker.DOMAIN) if not device_entity_ids: logger.error("No devices found to track") return False # Get the light IDs from the specified group - light_ids = group.get_entity_ids(hass, light_group, light.DOMAIN) + light_ids = group.get_entity_ids(light_group, light.DOMAIN) if not light_ids: logger.error("No lights found to turn on") @@ -85,9 +84,9 @@ def async_setup(hass, config): def async_turn_on_before_sunset(light_id): """Turn on lights.""" - if not device_tracker.is_on(hass) or light.is_on(hass, light_id): + if not device_tracker.is_on() or light.is_on(light_id): return - light.async_turn_on(hass, light_id, + light.async_turn_on(light_id, transition=LIGHT_TRANSITION_TIME.seconds, profile=light_profile) @@ -129,7 +128,7 @@ def async_setup(hass, config): @callback def check_light_on_dev_state_change(entity, old_state, new_state): """Handle tracked device state changes.""" - lights_are_on = group.is_on(hass, light_group) + lights_are_on = group.is_on(light_group) light_needed = not (lights_are_on or is_up(hass)) # These variables are needed for the elif check @@ -139,7 +138,7 @@ def async_setup(hass, config): # Do we need lights? if light_needed: logger.info("Home coming event for %s. Turning lights on", entity) - light.async_turn_on(hass, light_ids, profile=light_profile) + light.async_turn_on(light_ids, profile=light_profile) # Are we in the time span were we would turn on the lights # if someone would be home? @@ -152,7 +151,7 @@ def async_setup(hass, config): # when the fading in started and turn it on if so for index, light_id in enumerate(light_ids): if now > start_point + index * LIGHT_TRANSITION_TIME: - light.async_turn_on(hass, light_id) + light.async_turn_on(light_id) else: # If this light didn't happen to be turned on yet so @@ -169,12 +168,12 @@ def async_setup(hass, config): @callback def turn_off_lights_when_all_leave(entity, old_state, new_state): """Handle device group state change.""" - if not group.is_on(hass, light_group): + if not group.is_on(light_group): return logger.info( "Everyone has left but there are lights on. Turning them off") - light.async_turn_off(hass, light_ids) + light.async_turn_off(light_ids) async_track_state_change( hass, device_group, turn_off_lights_when_all_leave, diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 682496335a0..580c0272e46 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -9,24 +9,21 @@ from datetime import timedelta import logging from typing import Any, List, Sequence, Callable -import aiohttp -import async_timeout import voluptuous as vol from homeassistant.setup import async_prepare_setup_platform from homeassistant.core import callback from homeassistant.loader import bind_hass from homeassistant.components import group, zone +from homeassistant.components.zone.zone import async_active_zone from homeassistant.config import load_yaml_config_file, async_log_exception from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers import config_per_platform, discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.restore_state import async_get_last_state from homeassistant.helpers.typing import GPSType, ConfigType, HomeAssistantType import homeassistant.helpers.config_validation as cv -from homeassistant.loader import get_component import homeassistant.util as util from homeassistant.util.async_ import run_coroutine_threadsafe import homeassistant.util.dt as dt_util @@ -36,7 +33,7 @@ from homeassistant.helpers.event import async_track_utc_time_change from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, CONF_MAC, DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_ID, - CONF_ICON, ATTR_ICON) + CONF_ICON, ATTR_ICON, ATTR_NAME) _LOGGER = logging.getLogger(__name__) @@ -74,9 +71,7 @@ ATTR_GPS = 'gps' ATTR_HOST_NAME = 'host_name' ATTR_LOCATION_NAME = 'location_name' ATTR_MAC = 'mac' -ATTR_NAME = 'name' ATTR_SOURCE_TYPE = 'source_type' -ATTR_VENDOR = 'vendor' ATTR_CONSIDER_HOME = 'consider_home' SOURCE_TYPE_GPS = 'gps' @@ -325,17 +320,13 @@ class DeviceTracker(object): # During init, we ignore the group if self.group and self.track_new: self.group.async_set_group( - self.hass, util.slugify(GROUP_NAME_ALL_DEVICES), visible=False, + util.slugify(GROUP_NAME_ALL_DEVICES), visible=False, name=GROUP_NAME_ALL_DEVICES, add=[device.entity_id]) - # lookup mac vendor string to be stored in config - yield from device.set_vendor_for_mac() - self.hass.bus.async_fire(EVENT_NEW_DEVICE, { ATTR_ENTITY_ID: device.entity_id, ATTR_HOST_NAME: device.host_name, ATTR_MAC: device.mac, - ATTR_VENDOR: device.vendor, }) # update known_devices.yaml @@ -364,9 +355,9 @@ class DeviceTracker(object): entity_ids = [dev.entity_id for dev in self.devices.values() if dev.track] - self.group = get_component('group') + self.group = self.hass.components.group self.group.async_set_group( - self.hass, util.slugify(GROUP_NAME_ALL_DEVICES), visible=False, + util.slugify(GROUP_NAME_ALL_DEVICES), visible=False, name=GROUP_NAME_ALL_DEVICES, entity_ids=entity_ids) @callback @@ -413,7 +404,6 @@ class Device(Entity): consider_home = None # type: dt_util.dt.timedelta battery = None # type: int attributes = None # type: dict - vendor = None # type: str icon = None # type: str # Track if the last update of this device was HOME. @@ -423,7 +413,7 @@ class Device(Entity): def __init__(self, hass: HomeAssistantType, consider_home: timedelta, track: bool, dev_id: str, mac: str, name: str = None, picture: str = None, gravatar: str = None, icon: str = None, - hide_if_away: bool = False, vendor: str = None) -> None: + hide_if_away: bool = False) -> None: """Initialize a device.""" self.hass = hass self.entity_id = ENTITY_ID_FORMAT.format(dev_id) @@ -451,7 +441,6 @@ class Device(Entity): self.icon = icon self.away_hide = hide_if_away - self.vendor = vendor self.source_type = None @@ -551,7 +540,7 @@ class Device(Entity): elif self.location_name: self._state = self.location_name elif self.gps is not None and self.source_type == SOURCE_TYPE_GPS: - zone_state = zone.async_active_zone( + zone_state = async_active_zone( self.hass, self.gps[0], self.gps[1], self.gps_accuracy) if zone_state is None: self._state = STATE_NOT_HOME @@ -567,51 +556,6 @@ class Device(Entity): self._state = STATE_HOME self.last_update_home = True - @asyncio.coroutine - def set_vendor_for_mac(self): - """Set vendor string using api.macvendors.com.""" - self.vendor = yield from self.get_vendor_for_mac() - - @asyncio.coroutine - def get_vendor_for_mac(self): - """Try to find the vendor string for a given MAC address.""" - if not self.mac: - return None - - if '_' in self.mac: - _, mac = self.mac.split('_', 1) - else: - mac = self.mac - - if not len(mac.split(':')) == 6: - return 'unknown' - - # We only need the first 3 bytes of the MAC for a lookup - # this improves somewhat on privacy - oui_bytes = mac.split(':')[0:3] - # bytes like 00 get truncates to 0, API needs full bytes - oui = '{:02x}:{:02x}:{:02x}'.format(*[int(b, 16) for b in oui_bytes]) - url = 'http://api.macvendors.com/' + oui - try: - websession = async_get_clientsession(self.hass) - - with async_timeout.timeout(5, loop=self.hass.loop): - resp = yield from websession.get(url) - # mac vendor found, response is the string - if resp.status == 200: - vendor_string = yield from resp.text() - return vendor_string - # If vendor is not known to the API (404) or there - # was a failure during the lookup (500); set vendor - # to something other then None to prevent retry - # as the value is only relevant when it is to be stored - # in the 'known_devices.yaml' file which only happens - # the first time the device is seen. - return 'unknown' - except (asyncio.TimeoutError, aiohttp.ClientError): - # Same as above - return 'unknown' - @asyncio.coroutine def async_added_to_hass(self): """Add an entity.""" @@ -660,6 +604,17 @@ class DeviceScanner(object): """ return self.hass.async_add_job(self.get_device_name, device) + def get_extra_attributes(self, device: str) -> dict: + """Get the extra attributes of a device.""" + raise NotImplementedError() + + def async_get_extra_attributes(self, device: str) -> Any: + """Get the extra attributes of a device. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.get_extra_attributes, device) + def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta): """Load devices from YAML configuration file.""" @@ -685,7 +640,6 @@ def async_load_config(path: str, hass: HomeAssistantType, vol.Optional('picture', default=None): vol.Any(None, cv.string), vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All( cv.time_period, cv.positive_timedelta), - vol.Optional('vendor', default=None): vol.Any(None, cv.string), }) try: result = [] @@ -697,6 +651,8 @@ def async_load_config(path: str, hass: HomeAssistantType, return [] for dev_id, device in devices.items(): + # Deprecated option. We just ignore it to avoid breaking change + device.pop('vendor', None) try: device = dev_schema(device) device['dev_id'] = cv.slugify(dev_id) @@ -744,10 +700,20 @@ def async_setup_scanner_platform(hass: HomeAssistantType, config: ConfigType, host_name = yield from scanner.async_get_device_name(mac) seen.add(mac) + try: + extra_attributes = (yield from + scanner.async_get_extra_attributes(mac)) + except NotImplementedError: + extra_attributes = dict() + kwargs = { 'mac': mac, 'host_name': host_name, - 'source_type': SOURCE_TYPE_ROUTER + 'source_type': SOURCE_TYPE_ROUTER, + 'attributes': { + 'scanner': scanner.__class__.__name__, + **extra_attributes + } } zone_home = hass.states.get(zone.ENTITY_ID_HOME) @@ -772,7 +738,6 @@ def update_config(path: str, dev_id: str, device: Device): 'picture': device.config_picture, 'track': device.track, CONF_AWAY_HIDE: device.away_hide, - 'vendor': device.vendor, }} out.write('\n') out.write(dump(device)) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 14aea561c8e..7e9b10e9241 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -25,6 +25,7 @@ _LOGGER = logging.getLogger(__name__) CONF_PUB_KEY = 'pub_key' CONF_SSH_KEY = 'ssh_key' +CONF_REQUIRE_IP = 'require_ip' DEFAULT_SSH_PORT = 22 SECRET_GROUP = 'Password or SSH Key' @@ -36,6 +37,7 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']), vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']), vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port, + vol.Optional(CONF_REQUIRE_IP, default=True): cv.boolean, vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string, vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile, vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile @@ -115,6 +117,7 @@ class AsusWrtDeviceScanner(DeviceScanner): self.protocol = config[CONF_PROTOCOL] self.mode = config[CONF_MODE] self.port = config[CONF_PORT] + self.require_ip = config[CONF_REQUIRE_IP] if self.protocol == 'ssh': self.connection = SshConnection( @@ -172,7 +175,7 @@ class AsusWrtDeviceScanner(DeviceScanner): ret_devices = {} for key in devices: - if devices[key].ip is not None: + if not self.require_ip or devices[key].ip is not None: ret_devices[key] = devices[key] return ret_devices diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py index 9d41611d9a2..2ca519d225c 100644 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_tracker.py @@ -17,12 +17,15 @@ import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pybluez==0.22'] +REQUIREMENTS = ['pybluez==0.22', 'bt_proximity==0.1.2'] BT_PREFIX = 'BT_' +CONF_REQUEST_RSSI = 'request_rssi' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_TRACK_NEW): cv.boolean + vol.Optional(CONF_TRACK_NEW): cv.boolean, + vol.Optional(CONF_REQUEST_RSSI): cv.boolean }) @@ -30,11 +33,15 @@ def setup_scanner(hass, config, see, discovery_info=None): """Set up the Bluetooth Scanner.""" # pylint: disable=import-error import bluetooth + from bt_proximity import BluetoothRSSI - def see_device(device): + def see_device(mac, name, rssi=None): """Mark a device as seen.""" - see(mac=BT_PREFIX + device[0], host_name=device[1], - source_type=SOURCE_TYPE_BLUETOOTH) + attributes = {} + if rssi is not None: + attributes['rssi'] = rssi + see(mac="{}{}".format(BT_PREFIX, mac), host_name=name, + attributes=attributes, source_type=SOURCE_TYPE_BLUETOOTH) def discover_devices(): """Discover Bluetooth devices.""" @@ -64,27 +71,32 @@ def setup_scanner(hass, config, see, discovery_info=None): if track_new: for dev in discover_devices(): if dev[0] not in devs_to_track and \ - dev[0] not in devs_donot_track: + dev[0] not in devs_donot_track: devs_to_track.append(dev[0]) - see_device(dev) + see_device(dev[0], dev[1]) interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + request_rssi = config.get(CONF_REQUEST_RSSI, False) + def update_bluetooth(now): """Lookup Bluetooth device and update status.""" try: if track_new: for dev in discover_devices(): if dev[0] not in devs_to_track and \ - dev[0] not in devs_donot_track: + dev[0] not in devs_donot_track: devs_to_track.append(dev[0]) for mac in devs_to_track: _LOGGER.debug("Scanning %s", mac) result = bluetooth.lookup_name(mac, timeout=5) - if not result: + rssi = None + if request_rssi: + rssi = BluetoothRSSI(mac).request_rssi() + if result is None: # Could not lookup device name continue - see_device((mac, result)) + see_device(mac, result, rssi) except bluetooth.BluetoothError: _LOGGER.exception("Error looking up Bluetooth device") track_point_in_utc_time( diff --git a/homeassistant/components/device_tracker/bmw_connected_drive.py b/homeassistant/components/device_tracker/bmw_connected_drive.py index 6ba2681e4cd..f36afc622ee 100644 --- a/homeassistant/components/device_tracker/bmw_connected_drive.py +++ b/homeassistant/components/device_tracker/bmw_connected_drive.py @@ -36,16 +36,23 @@ class BMWDeviceTracker(object): self.vehicle = vehicle def update(self) -> None: - """Update the device info.""" - dev_id = slugify(self.vehicle.modelName) + """Update the device info. + + Only update the state in home assistant if tracking in + the car is enabled. + """ + dev_id = slugify(self.vehicle.name) + + if not self.vehicle.state.is_vehicle_tracking_enabled: + _LOGGER.debug('Tracking is disabled for vehicle %s', dev_id) + return + _LOGGER.debug('Updating %s', dev_id) attrs = { - 'trackr_id': dev_id, - 'id': dev_id, - 'name': self.vehicle.modelName + 'vin': self.vehicle.vin, } self._see( - dev_id=dev_id, host_name=self.vehicle.modelName, + dev_id=dev_id, host_name=self.vehicle.name, gps=self.vehicle.state.gps_position, attributes=attrs, icon='mdi:car' ) diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py new file mode 100644 index 00000000000..5f06946fc44 --- /dev/null +++ b/homeassistant/components/device_tracker/google_maps.py @@ -0,0 +1,93 @@ +""" +Support for Google Maps location sharing. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.google_maps/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, SOURCE_TYPE_GPS) +from homeassistant.const import ATTR_ID, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import slugify + +REQUIREMENTS = ['locationsharinglib==2.0.7'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_ADDRESS = 'address' +ATTR_FULL_NAME = 'full_name' +ATTR_LAST_SEEN = 'last_seen' +ATTR_NICKNAME = 'nickname' + +CREDENTIALS_FILE = '.google_maps_location_sharing.cookies' + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +def setup_scanner(hass, config: ConfigType, see, discovery_info=None): + """Set up the scanner.""" + scanner = GoogleMapsScanner(hass, config, see) + return scanner.success_init + + +class GoogleMapsScanner(object): + """Representation of an Google Maps location sharing account.""" + + def __init__(self, hass, config: ConfigType, see) -> None: + """Initialize the scanner.""" + from locationsharinglib import Service + from locationsharinglib.locationsharinglibexceptions import InvalidUser + + self.see = see + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + + try: + self.service = Service(self.username, self.password, + hass.config.path(CREDENTIALS_FILE)) + self._update_info() + + track_time_interval( + hass, self._update_info, MIN_TIME_BETWEEN_SCANS) + + self.success_init = True + + except InvalidUser: + _LOGGER.error("You have specified invalid login credentials") + self.success_init = False + + def _update_info(self, now=None): + for person in self.service.get_all_people(): + try: + dev_id = 'google_maps_{0}'.format(slugify(person.id)) + except TypeError: + _LOGGER.warning("No location(s) shared with this account") + return + + attrs = { + ATTR_ADDRESS: person.address, + ATTR_FULL_NAME: person.full_name, + ATTR_ID: person.id, + ATTR_LAST_SEEN: person.datetime, + ATTR_NICKNAME: person.nickname, + } + self.see( + dev_id=dev_id, + gps=(person.latitude, person.longitude), + picture=person.picture_url, + source_type=SOURCE_TYPE_GPS, + gps_accuracy=person.accuracy, + attributes=attrs, + ) diff --git a/homeassistant/components/device_tracker/gpslogger.py b/homeassistant/components/device_tracker/gpslogger.py index 1952e6d676d..68ea9ac88ae 100644 --- a/homeassistant/components/device_tracker/gpslogger.py +++ b/homeassistant/components/device_tracker/gpslogger.py @@ -4,7 +4,6 @@ Support for the GPSLogger platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.gpslogger/ """ -import asyncio import logging from hmac import compare_digest @@ -22,6 +21,7 @@ from homeassistant.components.http import ( from homeassistant.components.device_tracker import ( # NOQA DOMAIN, PLATFORM_SCHEMA ) +from homeassistant.helpers.typing import HomeAssistantType, ConfigType _LOGGER = logging.getLogger(__name__) @@ -32,8 +32,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_scanner(hass, config, async_see, discovery_info=None): +async def async_setup_scanner(hass: HomeAssistantType, config: ConfigType, + async_see, discovery_info=None): """Set up an endpoint for the GPSLogger application.""" hass.http.register_view(GPSLoggerView(async_see, config)) @@ -54,8 +54,7 @@ class GPSLoggerView(HomeAssistantView): # password is set self.requires_auth = self._password is None - @asyncio.coroutine - def get(self, request: Request): + async def get(self, request: Request): """Handle for GPSLogger message received as GET.""" hass = request.app['hass'] data = request.query diff --git a/homeassistant/components/device_tracker/hitron_coda.py b/homeassistant/components/device_tracker/hitron_coda.py index aa437eeef86..72817ca695c 100644 --- a/homeassistant/components/device_tracker/hitron_coda.py +++ b/homeassistant/components/device_tracker/hitron_coda.py @@ -14,15 +14,18 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_TYPE ) _LOGGER = logging.getLogger(__name__) +DEFAULT_TYPE = "rogers" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TYPE, default=DEFAULT_TYPE): cv.string, }) @@ -49,6 +52,11 @@ class HitronCODADeviceScanner(DeviceScanner): self._username = config.get(CONF_USERNAME) self._password = config.get(CONF_PASSWORD) + if config.get(CONF_TYPE) == "shaw": + self._type = 'pwd' + else: + self._type = 'pws' + self._userid = None self.success_init = self._update_info() @@ -74,7 +82,7 @@ class HitronCODADeviceScanner(DeviceScanner): try: data = [ ('user', self._username), - ('pws', self._password), + (self._type, self._password), ] res = requests.post(self._loginurl, data=data, timeout=10) except requests.exceptions.Timeout: diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index 781e3674550..8ea81e88440 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA, DOMAIN, ATTR_ATTRIBUTES, ENTITY_ID_FORMAT, DeviceScanner) -from homeassistant.components.zone import active_zone +from homeassistant.components.zone.zone import active_zone from homeassistant.helpers.event import track_utc_time_change import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify @@ -24,8 +24,9 @@ _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['pyicloud==0.9.1'] -CONF_IGNORED_DEVICES = 'ignored_devices' CONF_ACCOUNTNAME = 'account_name' +CONF_MAX_INTERVAL = 'max_interval' +CONF_GPS_ACCURACY_THRESHOLD = 'gps_accuracy_threshold' # entity attributes ATTR_ACCOUNTNAME = 'account_name' @@ -64,13 +65,15 @@ DEVICESTATUSCODES = { SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ACCOUNTNAME): vol.All(cv.ensure_list, [cv.slugify]), vol.Optional(ATTR_DEVICENAME): cv.slugify, - vol.Optional(ATTR_INTERVAL): cv.positive_int, + vol.Optional(ATTR_INTERVAL): cv.positive_int }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(ATTR_ACCOUNTNAME): cv.slugify, + vol.Optional(CONF_MAX_INTERVAL, default=30): cv.positive_int, + vol.Optional(CONF_GPS_ACCURACY_THRESHOLD, default=1000): cv.positive_int }) @@ -79,8 +82,11 @@ def setup_scanner(hass, config: dict, see, discovery_info=None): username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) account = config.get(CONF_ACCOUNTNAME, slugify(username.partition('@')[0])) + max_interval = config.get(CONF_MAX_INTERVAL) + gps_accuracy_threshold = config.get(CONF_GPS_ACCURACY_THRESHOLD) - icloudaccount = Icloud(hass, username, password, account, see) + icloudaccount = Icloud(hass, username, password, account, max_interval, + gps_accuracy_threshold, see) if icloudaccount.api is not None: ICLOUDTRACKERS[account] = icloudaccount @@ -96,6 +102,7 @@ def setup_scanner(hass, config: dict, see, discovery_info=None): for account in accounts: if account in ICLOUDTRACKERS: ICLOUDTRACKERS[account].lost_iphone(devicename) + hass.services.register(DOMAIN, 'icloud_lost_iphone', lost_iphone, schema=SERVICE_SCHEMA) @@ -106,6 +113,7 @@ def setup_scanner(hass, config: dict, see, discovery_info=None): for account in accounts: if account in ICLOUDTRACKERS: ICLOUDTRACKERS[account].update_icloud(devicename) + hass.services.register(DOMAIN, 'icloud_update', update_icloud, schema=SERVICE_SCHEMA) @@ -115,6 +123,7 @@ def setup_scanner(hass, config: dict, see, discovery_info=None): for account in accounts: if account in ICLOUDTRACKERS: ICLOUDTRACKERS[account].reset_account_icloud() + hass.services.register(DOMAIN, 'icloud_reset_account', reset_account_icloud, schema=SERVICE_SCHEMA) @@ -137,7 +146,8 @@ def setup_scanner(hass, config: dict, see, discovery_info=None): class Icloud(DeviceScanner): """Representation of an iCloud account.""" - def __init__(self, hass, username, password, name, see): + def __init__(self, hass, username, password, name, max_interval, + gps_accuracy_threshold, see): """Initialize an iCloud account.""" self.hass = hass self.username = username @@ -148,6 +158,8 @@ class Icloud(DeviceScanner): self.seen_devices = {} self._overridestates = {} self._intervals = {} + self._max_interval = max_interval + self._gps_accuracy_threshold = gps_accuracy_threshold self.see = see self._trusted_device = None @@ -348,7 +360,7 @@ class Icloud(DeviceScanner): self._overridestates[devicename] = None if currentzone is not None: - self._intervals[devicename] = 30 + self._intervals[devicename] = self._max_interval return if mindistance is None: @@ -363,7 +375,6 @@ class Icloud(DeviceScanner): if interval > 180: # Three hour drive? This is far enough that they might be flying - # home - check every half hour interval = 30 if battery is not None and battery <= 33 and mindistance > 3: @@ -403,22 +414,24 @@ class Icloud(DeviceScanner): status = device.status(DEVICESTATUSSET) battery = status.get('batteryLevel', 0) * 100 location = status['location'] - if location: - self.determine_interval( - devicename, location['latitude'], - location['longitude'], battery) - interval = self._intervals.get(devicename, 1) - attrs[ATTR_INTERVAL] = interval - accuracy = location['horizontalAccuracy'] - kwargs['dev_id'] = dev_id - kwargs['host_name'] = status['name'] - kwargs['gps'] = (location['latitude'], - location['longitude']) - kwargs['battery'] = battery - kwargs['gps_accuracy'] = accuracy - kwargs[ATTR_ATTRIBUTES] = attrs - self.see(**kwargs) - self.seen_devices[devicename] = True + if location and location['horizontalAccuracy']: + horizontal_accuracy = int(location['horizontalAccuracy']) + if horizontal_accuracy < self._gps_accuracy_threshold: + self.determine_interval( + devicename, location['latitude'], + location['longitude'], battery) + interval = self._intervals.get(devicename, 1) + attrs[ATTR_INTERVAL] = interval + accuracy = location['horizontalAccuracy'] + kwargs['dev_id'] = dev_id + kwargs['host_name'] = status['name'] + kwargs['gps'] = (location['latitude'], + location['longitude']) + kwargs['battery'] = battery + kwargs['gps_accuracy'] = accuracy + kwargs[ATTR_ATTRIBUTES] = attrs + self.see(**kwargs) + self.seen_devices[devicename] = True except PyiCloudNoDevicesException: _LOGGER.error("No iCloud Devices found") @@ -434,7 +447,7 @@ class Icloud(DeviceScanner): device.play_sound() def update_icloud(self, devicename=None): - """Authenticate against iCloud and scan for devices.""" + """Request device information from iCloud and update device_tracker.""" from pyicloud.exceptions import PyiCloudNoDevicesException if self.api is None: @@ -443,13 +456,13 @@ class Icloud(DeviceScanner): try: if devicename is not None: if devicename in self.devices: - self.devices[devicename].location() + self.update_device(devicename) else: _LOGGER.error("devicename %s unknown for account %s", devicename, self._attrs[ATTR_ACCOUNTNAME]) else: for device in self.devices: - self.devices[device].location() + self.update_device(device) except PyiCloudNoDevicesException: _LOGGER.error("No iCloud Devices found") diff --git a/homeassistant/components/device_tracker/luci.py b/homeassistant/components/device_tracker/luci.py index a4b826a009f..f479dea184b 100644 --- a/homeassistant/components/device_tracker/luci.py +++ b/homeassistant/components/device_tracker/luci.py @@ -15,14 +15,18 @@ import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import HomeAssistantError from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_SSL) _LOGGER = logging.getLogger(__name__) +DEFAULT_SSL = False + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean }) @@ -44,7 +48,9 @@ class LuciDeviceScanner(DeviceScanner): def __init__(self, config): """Initialize the scanner.""" - self.host = config[CONF_HOST] + host = config[CONF_HOST] + protocol = 'http' if not config[CONF_SSL] else 'https' + self.origin = '{}://{}'.format(protocol, host) self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] @@ -57,7 +63,7 @@ class LuciDeviceScanner(DeviceScanner): def refresh_token(self): """Get a new token.""" - self.token = _get_token(self.host, self.username, self.password) + self.token = _get_token(self.origin, self.username, self.password) def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" @@ -67,9 +73,9 @@ class LuciDeviceScanner(DeviceScanner): def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" if self.mac2name is None: - url = 'http://{}/cgi-bin/luci/rpc/uci'.format(self.host) - result = _req_json_rpc(url, 'get_all', 'dhcp', - params={'auth': self.token}) + url = '{}/cgi-bin/luci/rpc/uci'.format(self.origin) + result = _req_json_rpc( + url, 'get_all', 'dhcp', params={'auth': self.token}) if result: hosts = [x for x in result.values() if x['.type'] == 'host' and @@ -92,11 +98,11 @@ class LuciDeviceScanner(DeviceScanner): _LOGGER.info("Checking ARP") - url = 'http://{}/cgi-bin/luci/rpc/sys'.format(self.host) + url = '{}/cgi-bin/luci/rpc/sys'.format(self.origin) try: - result = _req_json_rpc(url, 'net.arptable', - params={'auth': self.token}) + result = _req_json_rpc( + url, 'net.arptable', params={'auth': self.token}) except InvalidLuciTokenError: _LOGGER.info("Refreshing token") self.refresh_token() @@ -146,10 +152,10 @@ def _req_json_rpc(url, method, *args, **kwargs): raise InvalidLuciTokenError else: - _LOGGER.error('Invalid response from luci: %s', res) + _LOGGER.error("Invalid response from luci: %s", res) -def _get_token(host, username, password): - """Get authentication token for the given host+username+password.""" - url = 'http://{}/cgi-bin/luci/rpc/auth'.format(host) +def _get_token(origin, username, password): + """Get authentication token for the given configuration.""" + url = '{}/cgi-bin/luci/rpc/auth'.format(origin) return _req_json_rpc(url, 'login', username, password) diff --git a/homeassistant/components/device_tracker/mercedesme.py b/homeassistant/components/device_tracker/mercedesme.py deleted file mode 100644 index dcc9e3ab2ec..00000000000 --- a/homeassistant/components/device_tracker/mercedesme.py +++ /dev/null @@ -1,74 +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/device_tracker.mercedesme/ -""" -import logging -from datetime import timedelta - -from homeassistant.components.mercedesme import DATA_MME -from homeassistant.helpers.event import track_time_interval -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['mercedesme'] - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) - - -def setup_scanner(hass, config, see, discovery_info=None): - """Set up the Mercedes ME tracker.""" - if discovery_info is None: - return False - - data = hass.data[DATA_MME].data - - if not data.cars: - return False - - MercedesMEDeviceTracker(hass, config, see, data) - - return True - - -class MercedesMEDeviceTracker(object): - """A class representing a Mercedes ME device tracker.""" - - def __init__(self, hass, config, see, data): - """Initialize the Mercedes ME device tracker.""" - self.see = see - self.data = data - self.update_info() - - track_time_interval( - hass, self.update_info, MIN_TIME_BETWEEN_SCANS) - - @Throttle(MIN_TIME_BETWEEN_SCANS) - def update_info(self, now=None): - """Update the device info.""" - for device in self.data.cars: - if not device['services'].get('VEHICLE_FINDER', False): - continue - - location = self.data.get_location(device["vin"]) - if location is None: - continue - - dev_id = device["vin"] - name = device["license"] - - lat = location['positionLat']['value'] - lon = location['positionLong']['value'] - attrs = { - 'trackr_id': dev_id, - 'id': dev_id, - 'name': name - } - self.see( - dev_id=dev_id, host_name=name, - gps=(lat, lon), attributes=attrs - ) - - return True diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 1d9161c0d45..a6a67749f76 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -73,7 +73,8 @@ class MikrotikScanner(DeviceScanner): self.host, self.username, self.password, - port=int(self.port) + port=int(self.port), + encoding='utf-8' ) try: @@ -175,7 +176,7 @@ class MikrotikScanner(DeviceScanner): for device in device_names if device.get('mac-address')} - if self.wireless_exist: + if self.wireless_exist or self.capsman_exist: self.last_results = { device.get('mac-address'): mac_names.get(device.get('mac-address')) diff --git a/homeassistant/components/device_tracker/mysensors.py b/homeassistant/components/device_tracker/mysensors.py index f68eb361ca0..b0d29bf0566 100644 --- a/homeassistant/components/device_tracker/mysensors.py +++ b/homeassistant/components/device_tracker/mysensors.py @@ -6,15 +6,15 @@ https://home-assistant.io/components/device_tracker.mysensors/ """ from homeassistant.components import mysensors from homeassistant.components.device_tracker import DOMAIN -from homeassistant.helpers.dispatcher import dispatcher_connect +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import slugify -def setup_scanner(hass, config, see, discovery_info=None): +async def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up the MySensors device scanner.""" new_devices = mysensors.setup_mysensors_platform( hass, DOMAIN, discovery_info, MySensorsDeviceScanner, - device_args=(see, )) + device_args=(async_see, )) if not new_devices: return False @@ -22,9 +22,9 @@ def setup_scanner(hass, config, see, discovery_info=None): dev_id = ( id(device.gateway), device.node_id, device.child_id, device.value_type) - dispatcher_connect( + async_dispatcher_connect( hass, mysensors.SIGNAL_CALLBACK.format(*dev_id), - device.update_callback) + device.async_update_callback) return True @@ -32,20 +32,20 @@ def setup_scanner(hass, config, see, discovery_info=None): class MySensorsDeviceScanner(mysensors.MySensorsDevice): """Represent a MySensors scanner.""" - def __init__(self, see, *args): + def __init__(self, async_see, *args): """Set up instance.""" super().__init__(*args) - self.see = see + self.async_see = async_see - def update_callback(self): + async def async_update_callback(self): """Update the device.""" - self.update() + await self.async_update() node = self.gateway.sensors[self.node_id] child = node.children[self.child_id] position = child.values[self.value_type] latitude, longitude, _ = position.split(',') - self.see( + await self.async_see( dev_id=slugify(self.name), host_name=self.name, gps=(latitude, longitude), diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index 25d5d38b2a7..0e48e3072b2 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -12,21 +12,27 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_SSL, + CONF_DEVICES, CONF_EXCLUDE) -REQUIREMENTS = ['pynetgear==0.3.3'] +REQUIREMENTS = ['pynetgear==0.4.0'] _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = 'routerlogin.net' -DEFAULT_USER = 'admin' -DEFAULT_PORT = 5000 +CONF_APS = 'accesspoints' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_USERNAME, default=DEFAULT_USER): cv.string, + vol.Optional(CONF_HOST, default=''): cv.string, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_USERNAME, default=''): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port + vol.Optional(CONF_PORT, default=None): vol.Any(None, cv.port), + vol.Optional(CONF_DEVICES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXCLUDE, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_APS, default=[]): + vol.All(cv.ensure_list, [cv.string]), }) @@ -34,11 +40,16 @@ def get_scanner(hass, config): """Validate the configuration and returns a Netgear scanner.""" info = config[DOMAIN] host = info.get(CONF_HOST) + ssl = info.get(CONF_SSL) username = info.get(CONF_USERNAME) password = info.get(CONF_PASSWORD) port = info.get(CONF_PORT) + devices = info.get(CONF_DEVICES) + excluded_devices = info.get(CONF_EXCLUDE) + accesspoints = info.get(CONF_APS) - scanner = NetgearDeviceScanner(host, username, password, port) + scanner = NetgearDeviceScanner(host, ssl, username, password, port, + devices, excluded_devices, accesspoints) return scanner if scanner.success_init else None @@ -46,16 +57,21 @@ def get_scanner(hass, config): class NetgearDeviceScanner(DeviceScanner): """Queries a Netgear wireless router using the SOAP-API.""" - def __init__(self, host, username, password, port): + def __init__(self, host, ssl, username, password, port, devices, + excluded_devices, accesspoints): """Initialize the scanner.""" import pynetgear + self.tracked_devices = devices + self.excluded_devices = excluded_devices + self.tracked_accesspoints = accesspoints + self.last_results = [] - self._api = pynetgear.Netgear(password, host, username, port) + self._api = pynetgear.Netgear(password, host, username, port, ssl) _LOGGER.info("Logging in") - results = self._api.get_attached_devices() + results = self.get_attached_devices() self.success_init = results is not None @@ -68,15 +84,50 @@ class NetgearDeviceScanner(DeviceScanner): """Scan for new devices and return a list with found device IDs.""" self._update_info() - return (device.mac for device in self.last_results) + devices = [] + + for dev in self.last_results: + tracked = (not self.tracked_devices or + dev.mac in self.tracked_devices or + dev.name in self.tracked_devices) + tracked = tracked and (not self.excluded_devices or not( + dev.mac in self.excluded_devices or + dev.name in self.excluded_devices)) + if tracked: + devices.append(dev.mac) + if (self.tracked_accesspoints and + dev.conn_ap_mac in self.tracked_accesspoints): + devices.append(dev.mac + "_" + dev.conn_ap_mac) + + return devices def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - try: - return next(result.name for result in self.last_results - if result.mac == device) - except StopIteration: - return None + """Return the name of the given device or the MAC if we don't know.""" + parts = device.split("_") + mac = parts[0] + ap_mac = None + if len(parts) > 1: + ap_mac = parts[1] + + name = None + for dev in self.last_results: + if dev.mac == mac: + name = dev.name + break + + if not name or name == "--": + name = mac + + if ap_mac: + ap_name = "Router" + for dev in self.last_results: + if dev.mac == ap_mac: + ap_name = dev.name + break + + return name + " on " + ap_name + + return name def _update_info(self): """Retrieve latest information from the Netgear router. @@ -88,9 +139,21 @@ class NetgearDeviceScanner(DeviceScanner): _LOGGER.info("Scanning") - results = self._api.get_attached_devices() + results = self.get_attached_devices() if results is None: _LOGGER.warning("Error scanning devices") self.last_results = results or [] + + def get_attached_devices(self): + """ + List attached devices with pynetgear. + + The v2 method takes more time and is more heavy on the router + so we only use it if we need connected AP info. + """ + if self.tracked_accesspoints: + return self._api.get_attached_devices_2() + + return self._api.get_attached_devices() diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index 23cb7ea8f9d..3c090e8cd3b 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -80,6 +80,8 @@ class NmapDeviceScanner(DeviceScanner): """Scan for new devices and return a list with found device IDs.""" self._update_info() + _LOGGER.debug("Nmap last results %s", self.last_results) + return [device.mac for device in self.last_results] def get_device_name(self, device): @@ -91,6 +93,13 @@ class NmapDeviceScanner(DeviceScanner): return filter_named[0] return None + def get_extra_attributes(self, device): + """Return the IP of the given device.""" + filter_ip = next(( + result.ip for result in self.last_results + if result.mac == device), None) + return {'ip': filter_ip} + def _update_info(self): """Scan the network for devices. diff --git a/homeassistant/components/device_tracker/tado.py b/homeassistant/components/device_tracker/tado.py index 11d12322ff5..ef816338ce9 100644 --- a/homeassistant/components/device_tracker/tado.py +++ b/homeassistant/components/device_tracker/tado.py @@ -100,7 +100,7 @@ class TadoDeviceScanner(DeviceScanner): last_results = [] try: - with async_timeout.timeout(10, loop=self.hass.loop): + with async_timeout.timeout(10): # Format the URL here, so we can log the template URL if # anything goes wrong without exposing username and password. url = self.tadoapiurl.format( diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index c75529655f4..f265014657b 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -95,7 +95,7 @@ class UbusDeviceScanner(DeviceScanner): return self.last_results def _generate_mac2name(self): - """Return empty MAC to name dict. Overriden if DHCP server is set.""" + """Return empty MAC to name dict. Overridden if DHCP server is set.""" self.mac2name = dict() @_refresh_on_access_denied @@ -103,6 +103,9 @@ class UbusDeviceScanner(DeviceScanner): """Return the name of the given device or None if we don't know.""" if self.mac2name is None: self._generate_mac2name() + if self.mac2name is None: + # Generation of mac2name dictionary failed + return None name = self.mac2name.get(device.upper(), None) return name @@ -204,7 +207,7 @@ def _req_json_rpc(url, session_id, rpcmethod, subsystem, method, **params): try: res = requests.post(url, data=data, timeout=5) - except requests.exceptions.Timeout: + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): return if res.status_code == 200: diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index 8663930c4e6..b7efe65dd01 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -98,7 +98,8 @@ class UnifiScanner(DeviceScanner): # Filter clients to provided SSID list if self._ssid_filter: clients = [client for client in clients - if client['essid'] in self._ssid_filter] + if 'essid' in client and + client['essid'] in self._ssid_filter] self._clients = { client['mac']: client @@ -121,3 +122,9 @@ class UnifiScanner(DeviceScanner): name = client.get('name') or client.get('hostname') _LOGGER.debug("Device mac %s name %s", device, name) return name + + def get_extra_attributes(self, device): + """Return the extra attributes of the device.""" + client = self._clients.get(device, {}) + _LOGGER.debug("Device mac %s attributes %s", device, client) + return client diff --git a/homeassistant/components/device_tracker/xiaomi_miio.py b/homeassistant/components/device_tracker/xiaomi_miio.py new file mode 100644 index 00000000000..c5769253657 --- /dev/null +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -0,0 +1,77 @@ +""" +Support for Xiaomi Mi WiFi Repeater 2. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/device_tracker.xiaomi_miio/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA, + DeviceScanner) +from homeassistant.const import (CONF_HOST, CONF_TOKEN) + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), +}) + +REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] + + +def get_scanner(hass, config): + """Return a Xiaomi MiIO device scanner.""" + from miio import WifiRepeater, DeviceException + + scanner = None + host = config[DOMAIN].get(CONF_HOST) + token = config[DOMAIN].get(CONF_TOKEN) + + _LOGGER.info( + "Initializing with host %s (token %s...)", host, token[:5]) + + try: + device = WifiRepeater(host, token) + device_info = device.info() + _LOGGER.info("%s %s %s detected", + device_info.model, + device_info.firmware_version, + device_info.hardware_version) + scanner = XiaomiMiioDeviceScanner(device) + except DeviceException as ex: + _LOGGER.error("Device unavailable or token incorrect: %s", ex) + + return scanner + + +class XiaomiMiioDeviceScanner(DeviceScanner): + """This class queries a Xiaomi Mi WiFi Repeater.""" + + def __init__(self, device): + """Initialize the scanner.""" + self.device = device + + async def async_scan_devices(self): + """Scan for devices and return a list containing found device ids.""" + from miio import DeviceException + + devices = [] + try: + station_info = await self.hass.async_add_job(self.device.status) + _LOGGER.debug("Got new station info: %s", station_info) + + for device in station_info['mat']: + devices.append(device['mac']) + + except DeviceException as ex: + _LOGGER.error("Got exception while fetching the state: %s", ex) + + return devices + + async def async_get_device_name(self, device): + """The repeater doesn't provide the name of the associated device.""" + return None diff --git a/homeassistant/components/dialogflow.py b/homeassistant/components/dialogflow.py index 63205c5479c..7a0918aab25 100644 --- a/homeassistant/components/dialogflow.py +++ b/homeassistant/components/dialogflow.py @@ -4,7 +4,6 @@ Support for Dialogflow webhook. For more details about this component, please refer to the documentation at https://home-assistant.io/components/dialogflow/ """ -import asyncio import logging import voluptuous as vol @@ -37,8 +36,7 @@ class DialogFlowError(HomeAssistantError): """Raised when a DialogFlow error happens.""" -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up Dialogflow component.""" hass.http.register_view(DialogflowIntentsView) @@ -51,16 +49,15 @@ class DialogflowIntentsView(HomeAssistantView): url = INTENTS_API_ENDPOINT name = 'api:dialogflow' - @asyncio.coroutine - def post(self, request): + async def post(self, request): """Handle Dialogflow.""" hass = request.app['hass'] - message = yield from request.json() + message = await request.json() _LOGGER.debug("Received Dialogflow request: %s", message) try: - response = yield from async_handle_message(hass, message) + response = await async_handle_message(hass, message) return b'' if response is None else self.json(response) except DialogFlowError as err: @@ -93,8 +90,7 @@ def dialogflow_error_response(hass, message, error): return dialogflow_response.as_dict() -@asyncio.coroutine -def async_handle_message(hass, message): +async def async_handle_message(hass, message): """Handle a DialogFlow message.""" req = message.get('result') action_incomplete = req['actionIncomplete'] @@ -110,7 +106,7 @@ def async_handle_message(hass, message): raise DialogFlowError( "You have not defined an action in your Dialogflow intent.") - intent_response = yield from intent.async_handle( + intent_response = await intent.async_handle( hass, DOMAIN, action, {key: {'value': value} for key, value in parameters.items()}) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 21a339602dd..69447b81cd4 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -6,7 +6,6 @@ Will emit EVENT_PLATFORM_DISCOVERED whenever a new service has been discovered. Knows which components handle certain types, will make sure they are loaded before the EVENT_PLATFORM_DISCOVERED is fired. """ -import asyncio import json from datetime import timedelta import logging @@ -14,6 +13,7 @@ import os import voluptuous as vol +from homeassistant import data_entry_flow from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_START import homeassistant.helpers.config_validation as cv @@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.3.0'] +REQUIREMENTS = ['netdisco==1.4.1'] DOMAIN = 'discovery' @@ -37,8 +37,17 @@ SERVICE_WINK = 'wink' SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_TELLDUSLIVE = 'tellstick' SERVICE_HUE = 'philips_hue' +SERVICE_KONNECTED = 'konnected' SERVICE_DECONZ = 'deconz' SERVICE_DAIKIN = 'daikin' +SERVICE_SABNZBD = 'sabnzbd' +SERVICE_SAMSUNG_PRINTER = 'samsung_printer' +SERVICE_HOMEKIT = 'homekit' + +CONFIG_ENTRY_HANDLERS = { + SERVICE_DECONZ: 'deconz', + SERVICE_HUE: 'hue', +} SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), @@ -51,9 +60,10 @@ SERVICE_HANDLERS = { SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), SERVICE_TELLDUSLIVE: ('tellduslive', None), - SERVICE_HUE: ('hue', None), - SERVICE_DECONZ: ('deconz', None), SERVICE_DAIKIN: ('daikin', None), + SERVICE_SABNZBD: ('sabnzbd', None), + SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'), + SERVICE_KONNECTED: ('konnected', None), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), 'plex_mediaserver': ('media_player', 'plex'), @@ -68,24 +78,33 @@ SERVICE_HANDLERS = { 'frontier_silicon': ('media_player', 'frontier_silicon'), 'openhome': ('media_player', 'openhome'), 'harmony': ('remote', 'harmony'), - 'sabnzbd': ('sensor', 'sabnzbd'), 'bose_soundtouch': ('media_player', 'soundtouch'), 'bluesound': ('media_player', 'bluesound'), 'songpal': ('media_player', 'songpal'), + 'kodi': ('media_player', 'kodi'), + 'volumio': ('media_player', 'volumio'), + 'nanoleaf_aurora': ('light', 'nanoleaf_aurora'), +} + +OPTIONAL_SERVICE_HANDLERS = { + SERVICE_HOMEKIT: ('homekit_controller', None), } CONF_IGNORE = 'ignore' +CONF_ENABLE = 'enable' CONFIG_SCHEMA = vol.Schema({ vol.Required(DOMAIN): vol.Schema({ vol.Optional(CONF_IGNORE, default=[]): - vol.All(cv.ensure_list, [vol.In(SERVICE_HANDLERS)]) + vol.All(cv.ensure_list, [ + vol.In(list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS))]), + vol.Optional(CONF_ENABLE, default=[]): + vol.All(cv.ensure_list, [vol.In(OPTIONAL_SERVICE_HANDLERS)]) }), }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Start a discovery service.""" from netdisco.discovery import NetworkDiscovery @@ -99,40 +118,52 @@ def async_setup(hass, config): # Platforms ignore by config ignored_platforms = config[DOMAIN][CONF_IGNORE] - @asyncio.coroutine - def new_service_found(service, info): + # Optional platforms enabled by config + enabled_platforms = config[DOMAIN][CONF_ENABLE] + + async def new_service_found(service, info): """Handle a new service if one is found.""" if service in ignored_platforms: logger.info("Ignoring service: %s %s", service, info) return - comp_plat = SERVICE_HANDLERS.get(service) - - # We do not know how to handle this service. - if not comp_plat: - logger.info("Unknown service discovered: %s %s", service, info) - return - discovery_hash = json.dumps([service, info], sort_keys=True) if discovery_hash in already_discovered: return already_discovered.add(discovery_hash) + if service in CONFIG_ENTRY_HANDLERS: + await hass.config_entries.flow.async_init( + CONFIG_ENTRY_HANDLERS[service], + source=data_entry_flow.SOURCE_DISCOVERY, + data=info + ) + return + + comp_plat = SERVICE_HANDLERS.get(service) + + if not comp_plat and service in enabled_platforms: + comp_plat = OPTIONAL_SERVICE_HANDLERS[service] + + # We do not know how to handle this service. + if not comp_plat: + logger.info("Unknown service discovered: %s %s", service, info) + return + logger.info("Found new service: %s %s", service, info) component, platform = comp_plat if platform is None: - yield from async_discover(hass, service, info, component, config) + await async_discover(hass, service, info, component, config) else: - yield from async_load_platform( + await async_load_platform( hass, component, platform, info, config) - @asyncio.coroutine - def scan_devices(now): + async def scan_devices(now): """Scan for devices.""" - results = yield from hass.async_add_job(_discover, netdisco) + results = await hass.async_add_job(_discover, netdisco) for result in results: hass.async_add_job(new_service_found(*result)) @@ -163,6 +194,7 @@ def _discover(netdisco): for disc in netdisco.discover(): for service in netdisco.get_info(disc): results.append((disc, service)) + finally: netdisco.stop() diff --git a/homeassistant/components/doorbird.py b/homeassistant/components/doorbird.py index be7adc034a0..48f229b49ca 100644 --- a/homeassistant/components/doorbird.py +++ b/homeassistant/components/doorbird.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD from homeassistant.components.http import HomeAssistantView import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['DoorBirdPy==0.1.2'] +REQUIREMENTS = ['DoorBirdPy==0.1.3'] _LOGGER = logging.getLogger(__name__) @@ -22,6 +22,7 @@ DOMAIN = 'doorbird' API_URL = '/api/{}'.format(DOMAIN) CONF_DOORBELL_EVENTS = 'doorbell_events' +CONF_CUSTOM_URL = 'hass_url_override' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -29,6 +30,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_DOORBELL_EVENTS): cv.boolean, + vol.Optional(CONF_CUSTOM_URL): cv.string, }) }, extra=vol.ALLOW_EXTRA) @@ -61,9 +63,17 @@ def setup(hass, config): # Provide an endpoint for the device to call to trigger events hass.http.register_view(DoorbirdRequestView()) + # Get the URL of this server + hass_url = hass.config.api.base_url + + # Override it if another is specified in the component configuration + if config[DOMAIN].get(CONF_CUSTOM_URL): + hass_url = config[DOMAIN].get(CONF_CUSTOM_URL) + _LOGGER.info("DoorBird will connect to this instance via %s", + hass_url) + # This will make HA the only service that gets doorbell events - url = '{}{}/{}'.format( - hass.config.api.base_url, API_URL, SENSOR_DOORBELL) + url = '{}{}/{}'.format(hass_url, API_URL, SENSOR_DOORBELL) device.reset_notifications() device.subscribe_notification(SENSOR_DOORBELL, url) diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index b7354b4f0a7..0d57740a83d 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -25,6 +25,8 @@ ATTR_OVERWRITE = 'overwrite' CONF_DOWNLOAD_DIR = 'download_dir' DOMAIN = 'downloader' +DOWNLOAD_FAILED_EVENT = 'download_failed' +DOWNLOAD_COMPLETED_EVENT = 'download_completed' SERVICE_DOWNLOAD_FILE = 'download_file' @@ -133,9 +135,19 @@ def setup(hass, config): fil.write(chunk) _LOGGER.debug("Downloading of %s done", url) + hass.bus.fire( + "{}_{}".format(DOMAIN, DOWNLOAD_COMPLETED_EVENT), { + 'url': url, + 'filename': filename + }) except requests.exceptions.ConnectionError: _LOGGER.exception("ConnectionError occurred for %s", url) + hass.bus.fire( + "{}_{}".format(DOMAIN, DOWNLOAD_FAILED_EVENT), { + 'url': url, + 'filename': filename + }) # Remove file if we started downloading but failed if final_path and os.path.isfile(final_path): diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 132e230c137..9c29cea704c 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.util import Throttle from homeassistant.util.json import save_json -REQUIREMENTS = ['python-ecobee-api==0.0.15'] +REQUIREMENTS = ['python-ecobee-api==0.0.18'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/egardia.py b/homeassistant/components/egardia.py index 2cfc44a407b..f350ea56bb4 100644 --- a/homeassistant/components/egardia.py +++ b/homeassistant/components/egardia.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_PORT, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, EVENT_HOMEASSISTANT_STOP) -REQUIREMENTS = ['pythonegardia==1.0.38'] +REQUIREMENTS = ['pythonegardia==1.0.39'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/eight_sleep.py b/homeassistant/components/eight_sleep.py index 7ae4ec862bb..3478d5cd08e 100644 --- a/homeassistant/components/eight_sleep.py +++ b/homeassistant/components/eight_sleep.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow -REQUIREMENTS = ['pyeight==0.0.7'] +REQUIREMENTS = ['pyeight==0.0.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 09ce1a57060..fd7f7147fdb 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -20,7 +20,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json from .hue_api import ( HueUsernameView, HueAllLightsStateView, HueOneLightStateView, - HueOneLightChangeView) + HueOneLightChangeView, HueGroupView) from .upnp import DescriptionXmlView, UPNPResponderThread DOMAIN = 'emulated_hue' @@ -104,6 +104,7 @@ def setup(hass, yaml_config): server.register_view(HueAllLightsStateView(config)) server.register_view(HueOneLightStateView(config)) server.register_view(HueOneLightChangeView(config)) + server.register_view(HueGroupView(config)) upnp_listener = UPNPResponderThread( config.host_ip_addr, config.listen_port, @@ -158,10 +159,6 @@ class Config(object): "Listen port not specified, defaulting to %s", self.listen_port) - if self.type == TYPE_GOOGLE and self.listen_port != 80: - _LOGGER.warning("When targeting Google Home, listening port has " - "to be port 80") - # Get whether or not UPNP binds to multicast address (239.255.255.250) # or to the unicast address (host_ip_addr) self.upnp_bind_multicast = conf.get( diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 5d97ef3cea4..2b74984e4ca 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -51,6 +51,29 @@ class HueUsernameView(HomeAssistantView): return self.json([{'success': {'username': '12345678901234567890'}}]) +class HueGroupView(HomeAssistantView): + """Group handler to get Logitech Pop working.""" + + url = '/api/{username}/groups/0/action' + name = 'emulated_hue:groups:state' + requires_auth = False + + def __init__(self, config): + """Initialize the instance of the view.""" + self.config = config + + @core.callback + def put(self, request, username): + """Process a request to make the Logitech Pop working.""" + return self.json([{ + 'error': { + 'address': '/groups/0/action/scene', + 'type': 7, + 'description': 'invalid value, dummy for parameter, scene' + } + }]) + + class HueAllLightsStateView(HomeAssistantView): """Handle requests for getting and setting info about entities.""" diff --git a/homeassistant/components/eufy.py b/homeassistant/components/eufy.py new file mode 100644 index 00000000000..e86e7348d58 --- /dev/null +++ b/homeassistant/components/eufy.py @@ -0,0 +1,77 @@ +""" +Support for Eufy devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/eufy/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS, \ + CONF_DEVICES, CONF_USERNAME, CONF_PASSWORD, CONF_TYPE, CONF_NAME +from homeassistant.helpers import discovery + +import homeassistant.helpers.config_validation as cv + + +REQUIREMENTS = ['lakeside==0.7'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'eufy' + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_TYPE): cv.string, + vol.Optional(CONF_NAME): cv.string +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, + [DEVICE_SCHEMA]), + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + +EUFY_DISPATCH = { + 'T1011': 'light', + 'T1012': 'light', + 'T1013': 'light', + 'T1201': 'switch', + 'T1202': 'switch', + 'T1211': 'switch' +} + + +def setup(hass, config): + """Set up Eufy devices.""" + # pylint: disable=import-error + import lakeside + + if CONF_USERNAME in config[DOMAIN] and CONF_PASSWORD in config[DOMAIN]: + data = lakeside.get_devices(config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD]) + for device in data: + kind = device['type'] + if kind not in EUFY_DISPATCH: + continue + discovery.load_platform(hass, EUFY_DISPATCH[kind], DOMAIN, device, + config) + + for device_info in config[DOMAIN][CONF_DEVICES]: + kind = device_info['type'] + if kind not in EUFY_DISPATCH: + continue + device = {} + device['address'] = device_info['address'] + device['code'] = device_info['access_token'] + device['type'] = device_info['type'] + device['name'] = device_info['name'] + discovery.load_platform(hass, EUFY_DISPATCH[kind], DOMAIN, device, + config) + + return True diff --git a/homeassistant/components/fan/insteon_plm.py b/homeassistant/components/fan/insteon_plm.py index f30abdbaa30..0911295d090 100644 --- a/homeassistant/components/fan/insteon_plm.py +++ b/homeassistant/components/fan/insteon_plm.py @@ -31,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) @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] diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 95ff587c613..6fa506edec6 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -4,7 +4,6 @@ Support for MQTT fans. For more details about this platform, please refer to the documentation https://home-assistant.io/components/fan.mqtt/ """ -import asyncio import logging import voluptuous as vol @@ -19,6 +18,7 @@ from homeassistant.components.mqtt import ( CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, MqttAvailability) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType, ConfigType from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, FanEntity, SUPPORT_SET_SPEED, SUPPORT_OSCILLATE, @@ -77,8 +77,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up the MQTT fan platform.""" if discovery_info is not None: config = PLATFORM_SCHEMA(discovery_info) @@ -149,10 +149,9 @@ class MqttFan(MqttAvailability, FanEntity): self._supported_features |= (topic[CONF_SPEED_STATE_TOPIC] is not None and SUPPORT_SET_SPEED) - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe to MQTT events.""" - yield from super().async_added_to_hass() + await super().async_added_to_hass() templates = {} for key, tpl in list(self._templates.items()): @@ -173,7 +172,7 @@ class MqttFan(MqttAvailability, FanEntity): self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_STATE_TOPIC], state_received, self._qos) @@ -190,7 +189,7 @@ class MqttFan(MqttAvailability, FanEntity): self.async_schedule_update_ha_state() if self._topic[CONF_SPEED_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_SPEED_STATE_TOPIC], speed_received, self._qos) self._speed = SPEED_OFF @@ -206,7 +205,7 @@ class MqttFan(MqttAvailability, FanEntity): self.async_schedule_update_ha_state() if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_OSCILLATION_STATE_TOPIC], oscillation_received, self._qos) self._oscillation = False @@ -251,8 +250,7 @@ class MqttFan(MqttAvailability, FanEntity): """Return the oscillation state.""" return self._oscillation - @asyncio.coroutine - def async_turn_on(self, speed: str = None, **kwargs) -> None: + async def async_turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the entity. This method is a coroutine. @@ -261,10 +259,9 @@ class MqttFan(MqttAvailability, FanEntity): self.hass, self._topic[CONF_COMMAND_TOPIC], self._payload[STATE_ON], self._qos, self._retain) if speed: - yield from self.async_set_speed(speed) + await self.async_set_speed(speed) - @asyncio.coroutine - def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs) -> None: """Turn off the entity. This method is a coroutine. @@ -273,8 +270,7 @@ class MqttFan(MqttAvailability, FanEntity): self.hass, self._topic[CONF_COMMAND_TOPIC], self._payload[STATE_OFF], self._qos, self._retain) - @asyncio.coroutine - def async_set_speed(self, speed: str) -> None: + async def async_set_speed(self, speed: str) -> None: """Set the speed of the fan. This method is a coroutine. @@ -299,8 +295,7 @@ class MqttFan(MqttAvailability, FanEntity): self._speed = speed self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_oscillate(self, oscillating: bool) -> None: + async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation. This method is a coroutine. diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index a306cf7767c..039cc33f748 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -51,8 +51,8 @@ set_direction: description: Name(s) of the entities to toggle example: 'fan.living_room' direction: - description: The direction to rotate - example: 'left' + description: The direction to rotate. Either 'forward' or 'reverse' + example: 'forward' dyson_set_night_mode: description: Set the fan in night mode. @@ -68,50 +68,50 @@ xiaomi_miio_set_buzzer_on: description: Turn the buzzer on. fields: entity_id: - description: Name of the air purifier entity. - example: 'fan.xiaomi_air_purifier' + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' xiaomi_miio_set_buzzer_off: description: Turn the buzzer off. fields: entity_id: - description: Name of the air purifier entity. - example: 'fan.xiaomi_air_purifier' + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' xiaomi_miio_set_led_on: description: Turn the led on. fields: entity_id: - description: Name of the air purifier entity. - example: 'fan.xiaomi_air_purifier' + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' xiaomi_miio_set_led_off: description: Turn the led off. fields: entity_id: - description: Name of the air purifier entity. - example: 'fan.xiaomi_air_purifier' + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' xiaomi_miio_set_child_lock_on: description: Turn the child lock on. fields: entity_id: - description: Name of the air purifier entity. - example: 'fan.xiaomi_air_purifier' + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' xiaomi_miio_set_child_lock_off: description: Turn the child lock off. fields: entity_id: - description: Name of the air purifier entity. - example: 'fan.xiaomi_air_purifier' + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' xiaomi_miio_set_favorite_level: description: Set the favorite level. fields: entity_id: - description: Name of the air purifier entity. - example: 'fan.xiaomi_air_purifier' + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' level: description: Level, between 0 and 16. example: 1 @@ -120,8 +120,87 @@ xiaomi_miio_set_led_brightness: description: Set the led brightness. fields: entity_id: - description: Name of the air purifier entity. - example: 'fan.xiaomi_air_purifier' + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' brightness: description: Brightness (0 = Bright, 1 = Dim, 2 = Off) example: 1 + +xiaomi_miio_set_auto_detect_on: + description: Turn the auto detect on. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' + +xiaomi_miio_set_auto_detect_off: + description: Turn the auto detect off. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' + +xiaomi_miio_set_learn_mode_on: + description: Turn the learn mode on. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' + +xiaomi_miio_set_learn_mode_off: + description: Turn the learn mode off. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' + +xiaomi_miio_set_volume: + description: Set the sound volume. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' + volume: + description: Volume, between 0 and 100. + example: 50 + +xiaomi_miio_reset_filter: + description: Reset the filter lifetime and usage. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' + +xiaomi_miio_set_extra_features: + description: Manipulates a storage register which advertises extra features. The Mi Home app evaluates the value. A feature called "turbo mode" is unlocked in the app on value 1. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' + features: + description: Integer, known values are 0 (default) and 1 (turbo mode). + example: 1 + +xiaomi_miio_set_target_humidity: + description: Set the target humidity. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' + humidity: + description: Target humidity. Allowed values are 30, 40, 50, 60, 70 and 80. + example: 50 + +xiaomi_miio_set_dry_on: + description: Turn the dry mode on. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' + +xiaomi_miio_set_dry_off: + description: Turn the dry mode off. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'fan.xiaomi_miio_device' diff --git a/homeassistant/components/fan/template.py b/homeassistant/components/fan/template.py new file mode 100644 index 00000000000..a40437e719b --- /dev/null +++ b/homeassistant/components/fan/template.py @@ -0,0 +1,389 @@ +""" +Support for Template fans. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/fan.template/ +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + CONF_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, CONF_ENTITY_ID, + STATE_ON, STATE_OFF, MATCH_ALL, EVENT_HOMEASSISTANT_START, + STATE_UNKNOWN) + +from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.components.fan import ( + SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED, SUPPORT_OSCILLATE, + FanEntity, ATTR_SPEED, ATTR_OSCILLATING, ENTITY_ID_FORMAT, + SUPPORT_DIRECTION, DIRECTION_FORWARD, DIRECTION_REVERSE, ATTR_DIRECTION) + +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.script import Script + +_LOGGER = logging.getLogger(__name__) + +CONF_FANS = 'fans' +CONF_SPEED_LIST = 'speeds' +CONF_SPEED_TEMPLATE = 'speed_template' +CONF_OSCILLATING_TEMPLATE = 'oscillating_template' +CONF_DIRECTION_TEMPLATE = 'direction_template' +CONF_ON_ACTION = 'turn_on' +CONF_OFF_ACTION = 'turn_off' +CONF_SET_SPEED_ACTION = 'set_speed' +CONF_SET_OSCILLATING_ACTION = 'set_oscillating' +CONF_SET_DIRECTION_ACTION = 'set_direction' + +_VALID_STATES = [STATE_ON, STATE_OFF] +_VALID_OSC = [True, False] +_VALID_DIRECTIONS = [DIRECTION_FORWARD, DIRECTION_REVERSE] + +FAN_SCHEMA = vol.Schema({ + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template, + vol.Optional(CONF_DIRECTION_TEMPLATE): cv.template, + + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + + vol.Optional(CONF_SET_SPEED_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, + + vol.Optional( + CONF_SPEED_LIST, + default=[SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + ): cv.ensure_list, + + vol.Optional(CONF_ENTITY_ID): cv.entity_ids +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FANS): vol.Schema({cv.slug: FAN_SCHEMA}), +}) + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None +): + """Set up the Template Fans.""" + fans = [] + + for device, device_config in config[CONF_FANS].items(): + friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) + + state_template = device_config[CONF_VALUE_TEMPLATE] + speed_template = device_config.get(CONF_SPEED_TEMPLATE) + oscillating_template = device_config.get( + CONF_OSCILLATING_TEMPLATE + ) + direction_template = device_config.get(CONF_DIRECTION_TEMPLATE) + + on_action = device_config[CONF_ON_ACTION] + off_action = device_config[CONF_OFF_ACTION] + set_speed_action = device_config.get(CONF_SET_SPEED_ACTION) + set_oscillating_action = device_config.get(CONF_SET_OSCILLATING_ACTION) + set_direction_action = device_config.get(CONF_SET_DIRECTION_ACTION) + + speed_list = device_config[CONF_SPEED_LIST] + + entity_ids = set() + manual_entity_ids = device_config.get(CONF_ENTITY_ID) + + for template in (state_template, speed_template, oscillating_template, + direction_template): + if template is None: + continue + template.hass = hass + + if entity_ids == MATCH_ALL or manual_entity_ids is not None: + continue + + template_entity_ids = template.extract_entities() + if template_entity_ids == MATCH_ALL: + entity_ids = MATCH_ALL + else: + entity_ids |= set(template_entity_ids) + + if manual_entity_ids is not None: + entity_ids = manual_entity_ids + elif entity_ids != MATCH_ALL: + entity_ids = list(entity_ids) + + fans.append( + TemplateFan( + hass, device, friendly_name, + state_template, speed_template, oscillating_template, + direction_template, on_action, off_action, set_speed_action, + set_oscillating_action, set_direction_action, speed_list, + entity_ids + ) + ) + + async_add_devices(fans) + + +class TemplateFan(FanEntity): + """A template fan component.""" + + def __init__(self, hass, device_id, friendly_name, + state_template, speed_template, oscillating_template, + direction_template, on_action, off_action, set_speed_action, + set_oscillating_action, set_direction_action, speed_list, + entity_ids): + """Initialize the fan.""" + self.hass = hass + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, device_id, hass=hass) + self._name = friendly_name + + self._template = state_template + self._speed_template = speed_template + self._oscillating_template = oscillating_template + self._direction_template = direction_template + self._supported_features = 0 + + self._on_script = Script(hass, on_action) + self._off_script = Script(hass, off_action) + + self._set_speed_script = None + if set_speed_action: + self._set_speed_script = Script(hass, set_speed_action) + + self._set_oscillating_script = None + if set_oscillating_action: + self._set_oscillating_script = Script(hass, set_oscillating_action) + + self._set_direction_script = None + if set_direction_action: + self._set_direction_script = Script(hass, set_direction_action) + + self._state = STATE_OFF + self._speed = None + self._oscillating = None + self._direction = None + + self._template.hass = self.hass + if self._speed_template: + self._speed_template.hass = self.hass + self._supported_features |= SUPPORT_SET_SPEED + if self._oscillating_template: + self._oscillating_template.hass = self.hass + self._supported_features |= SUPPORT_OSCILLATE + if self._direction_template: + self._direction_template.hass = self.hass + self._supported_features |= SUPPORT_DIRECTION + + self._entities = entity_ids + # List of valid speeds + self._speed_list = speed_list + + @property + def name(self): + """Return the display name of this fan.""" + return self._name + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + @property + def speed_list(self: ToggleEntity) -> list: + """Get the list of available speeds.""" + return self._speed_list + + @property + def is_on(self): + """Return true if device is on.""" + return self._state == STATE_ON + + @property + def speed(self): + """Return the current speed.""" + return self._speed + + @property + def oscillating(self): + """Return the oscillation state.""" + return self._oscillating + + @property + def direction(self): + """Return the oscillation state.""" + return self._direction + + @property + def should_poll(self): + """Return the polling state.""" + return False + + # pylint: disable=arguments-differ + async def async_turn_on(self, speed: str = None) -> None: + """Turn on the fan.""" + await self._on_script.async_run() + self._state = STATE_ON + + if speed is not None: + await self.async_set_speed(speed) + + # pylint: disable=arguments-differ + async def async_turn_off(self) -> None: + """Turn off the fan.""" + await self._off_script.async_run() + self._state = STATE_OFF + + async def async_set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + if self._set_speed_script is None: + return + + if speed in self._speed_list: + self._speed = speed + await self._set_speed_script.async_run({ATTR_SPEED: speed}) + else: + _LOGGER.error( + 'Received invalid speed: %s. ' + + 'Expected: %s.', + speed, self._speed_list) + + async def async_oscillate(self, oscillating: bool) -> None: + """Set oscillation of the fan.""" + if self._set_oscillating_script is None: + return + + if oscillating in _VALID_OSC: + self._oscillating = oscillating + await self._set_oscillating_script.async_run( + {ATTR_OSCILLATING: oscillating}) + else: + _LOGGER.error( + 'Received invalid oscillating value: %s. ' + + 'Expected: %s.', + oscillating, ', '.join(_VALID_OSC)) + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + if self._set_direction_script is None: + return + + if direction in _VALID_DIRECTIONS: + self._direction = direction + await self._set_direction_script.async_run( + {ATTR_DIRECTION: direction}) + else: + _LOGGER.error( + 'Received invalid direction: %s. ' + + 'Expected: %s.', + direction, ', '.join(_VALID_DIRECTIONS)) + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def template_fan_state_listener(entity, old_state, new_state): + """Handle target device state changes.""" + self.async_schedule_update_ha_state(True) + + @callback + def template_fan_startup(event): + """Update template on startup.""" + self.hass.helpers.event.async_track_state_change( + self._entities, template_fan_state_listener) + + self.async_schedule_update_ha_state(True) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, template_fan_startup) + + async def async_update(self): + """Update the state from the template.""" + # Update state + try: + state = self._template.async_render() + except TemplateError as ex: + _LOGGER.error(ex) + state = None + self._state = None + + # Validate state + if state in _VALID_STATES: + self._state = state + elif state == STATE_UNKNOWN: + self._state = None + else: + _LOGGER.error( + 'Received invalid fan is_on state: %s. ' + + 'Expected: %s.', + state, ', '.join(_VALID_STATES)) + self._state = None + + # Update speed if 'speed_template' is configured + if self._speed_template is not None: + try: + speed = self._speed_template.async_render() + except TemplateError as ex: + _LOGGER.error(ex) + speed = None + self._state = None + + # Validate speed + if speed in self._speed_list: + self._speed = speed + elif speed == STATE_UNKNOWN: + self._speed = None + else: + _LOGGER.error( + 'Received invalid speed: %s. ' + + 'Expected: %s.', + speed, self._speed_list) + self._speed = None + + # Update oscillating if 'oscillating_template' is configured + if self._oscillating_template is not None: + try: + oscillating = self._oscillating_template.async_render() + except TemplateError as ex: + _LOGGER.error(ex) + oscillating = None + self._state = None + + # Validate osc + if oscillating == 'True' or oscillating is True: + self._oscillating = True + elif oscillating == 'False' or oscillating is False: + self._oscillating = False + elif oscillating == STATE_UNKNOWN: + self._oscillating = None + else: + _LOGGER.error( + 'Received invalid oscillating: %s. ' + + 'Expected: True/False.', oscillating) + self._oscillating = None + + # Update direction if 'direction_template' is configured + if self._direction_template is not None: + try: + direction = self._direction_template.async_render() + except TemplateError as ex: + _LOGGER.error(ex) + direction = None + self._state = None + + # Validate speed + if direction in _VALID_DIRECTIONS: + self._direction = direction + elif direction == STATE_UNKNOWN: + self._direction = None + else: + _LOGGER.error( + 'Received invalid direction: %s. ' + + 'Expected: %s.', + direction, ', '.join(_VALID_DIRECTIONS)) + self._direction = None diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 09df55200a2..2acc3895f3e 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -1,16 +1,16 @@ """ -Support for Xiaomi Mi Air Purifier 2. +Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier. For more details about this platform, please refer to the documentation https://home-assistant.io/components/fan.xiaomi_miio/ """ import asyncio +from enum import Enum from functools import partial import logging import voluptuous as vol -from homeassistant.helpers.entity import ToggleEntity from homeassistant.components.fan import (FanEntity, PLATFORM_SCHEMA, SUPPORT_SET_SPEED, DOMAIN, ) from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, @@ -20,17 +20,40 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'Xiaomi Air Purifier' -PLATFORM = 'xiaomi_miio' +DEFAULT_NAME = 'Xiaomi Miio Device' +DATA_KEY = 'fan.xiaomi_miio' + +CONF_MODEL = 'model' +MODEL_AIRPURIFIER_PRO = 'zhimi.airpurifier.v6' +MODEL_AIRPURIFIER_V3 = 'zhimi.airpurifier.v3' +MODEL_AIRHUMIDIFIER_V1 = 'zhimi.humidifier.v1' +MODEL_AIRHUMIDIFIER_CA = 'zhimi.humidifier.ca1' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MODEL): vol.In( + ['zhimi.airpurifier.m1', + 'zhimi.airpurifier.m2', + 'zhimi.airpurifier.ma1', + 'zhimi.airpurifier.ma2', + 'zhimi.airpurifier.sa1', + 'zhimi.airpurifier.sa2', + 'zhimi.airpurifier.v1', + 'zhimi.airpurifier.v2', + 'zhimi.airpurifier.v3', + 'zhimi.airpurifier.v5', + 'zhimi.airpurifier.v6', + 'zhimi.humidifier.v1', + 'zhimi.humidifier.ca1']), }) -REQUIREMENTS = ['python-miio==0.3.8'] +REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] +ATTR_MODEL = 'model' + +# Air Purifier ATTR_TEMPERATURE = 'temperature' ATTR_HUMIDITY = 'humidity' ATTR_AIR_QUALITY_INDEX = 'aqi' @@ -45,20 +68,190 @@ ATTR_LED_BRIGHTNESS = 'led_brightness' ATTR_MOTOR_SPEED = 'motor_speed' ATTR_AVERAGE_AIR_QUALITY_INDEX = 'average_aqi' ATTR_PURIFY_VOLUME = 'purify_volume' - ATTR_BRIGHTNESS = 'brightness' ATTR_LEVEL = 'level' +ATTR_MOTOR2_SPEED = 'motor2_speed' +ATTR_ILLUMINANCE = 'illuminance' +ATTR_FILTER_RFID_PRODUCT_ID = 'filter_rfid_product_id' +ATTR_FILTER_RFID_TAG = 'filter_rfid_tag' +ATTR_FILTER_TYPE = 'filter_type' +ATTR_LEARN_MODE = 'learn_mode' +ATTR_SLEEP_TIME = 'sleep_time' +ATTR_SLEEP_LEARN_COUNT = 'sleep_mode_learn_count' +ATTR_EXTRA_FEATURES = 'extra_features' +ATTR_FEATURES = 'features' +ATTR_TURBO_MODE_SUPPORTED = 'turbo_mode_supported' +ATTR_AUTO_DETECT = 'auto_detect' +ATTR_SLEEP_MODE = 'sleep_mode' +ATTR_VOLUME = 'volume' +ATTR_USE_TIME = 'use_time' +ATTR_BUTTON_PRESSED = 'button_pressed' + +# Air Humidifier +ATTR_TARGET_HUMIDITY = 'target_humidity' +ATTR_TRANS_LEVEL = 'trans_level' +ATTR_HARDWARE_VERSION = 'hardware_version' + +# Air Humidifier CA +ATTR_SPEED = 'speed' +ATTR_DEPTH = 'depth' +ATTR_DRY = 'dry' + +# Map attributes to properties of the state object +AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = { + ATTR_TEMPERATURE: 'temperature', + ATTR_HUMIDITY: 'humidity', + ATTR_AIR_QUALITY_INDEX: 'aqi', + ATTR_MODE: 'mode', + ATTR_FILTER_HOURS_USED: 'filter_hours_used', + ATTR_FILTER_LIFE: 'filter_life_remaining', + ATTR_FAVORITE_LEVEL: 'favorite_level', + ATTR_CHILD_LOCK: 'child_lock', + ATTR_LED: 'led', + ATTR_MOTOR_SPEED: 'motor_speed', + ATTR_AVERAGE_AIR_QUALITY_INDEX: 'average_aqi', + ATTR_PURIFY_VOLUME: 'purify_volume', + ATTR_LEARN_MODE: 'learn_mode', + ATTR_SLEEP_TIME: 'sleep_time', + ATTR_SLEEP_LEARN_COUNT: 'sleep_mode_learn_count', + ATTR_EXTRA_FEATURES: 'extra_features', + ATTR_TURBO_MODE_SUPPORTED: 'turbo_mode_supported', + ATTR_AUTO_DETECT: 'auto_detect', + ATTR_USE_TIME: 'use_time', + ATTR_BUTTON_PRESSED: 'button_pressed', +} + +AVAILABLE_ATTRIBUTES_AIRPURIFIER = { + **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, + ATTR_BUZZER: 'buzzer', + ATTR_LED_BRIGHTNESS: 'led_brightness', + ATTR_SLEEP_MODE: 'sleep_mode', +} + +AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO = { + **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON, + ATTR_FILTER_RFID_PRODUCT_ID: 'filter_rfid_product_id', + ATTR_FILTER_RFID_TAG: 'filter_rfid_tag', + ATTR_FILTER_TYPE: 'filter_type', + ATTR_ILLUMINANCE: 'illuminance', + ATTR_MOTOR2_SPEED: 'motor2_speed', + ATTR_VOLUME: 'volume', +} + +AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { + # Common set isn't used here. It's a very basic version of the device. + ATTR_AIR_QUALITY_INDEX: 'aqi', + ATTR_MODE: 'mode', + ATTR_LED: 'led', + ATTR_BUZZER: 'buzzer', + ATTR_CHILD_LOCK: 'child_lock', + ATTR_ILLUMINANCE: 'illuminance', + ATTR_FILTER_HOURS_USED: 'filter_hours_used', + ATTR_FILTER_LIFE: 'filter_life_remaining', + ATTR_MOTOR_SPEED: 'motor_speed', + # perhaps supported but unconfirmed + ATTR_AVERAGE_AIR_QUALITY_INDEX: 'average_aqi', + ATTR_VOLUME: 'volume', + ATTR_MOTOR2_SPEED: 'motor2_speed', + ATTR_FILTER_RFID_PRODUCT_ID: 'filter_rfid_product_id', + ATTR_FILTER_RFID_TAG: 'filter_rfid_tag', + ATTR_FILTER_TYPE: 'filter_type', + ATTR_PURIFY_VOLUME: 'purify_volume', + ATTR_LEARN_MODE: 'learn_mode', + ATTR_SLEEP_TIME: 'sleep_time', + ATTR_SLEEP_LEARN_COUNT: 'sleep_mode_learn_count', + ATTR_EXTRA_FEATURES: 'extra_features', + ATTR_AUTO_DETECT: 'auto_detect', + ATTR_USE_TIME: 'use_time', + ATTR_BUTTON_PRESSED: 'button_pressed', +} + +AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER = { + ATTR_TEMPERATURE: 'temperature', + ATTR_HUMIDITY: 'humidity', + ATTR_MODE: 'mode', + ATTR_BUZZER: 'buzzer', + ATTR_CHILD_LOCK: 'child_lock', + ATTR_TRANS_LEVEL: 'trans_level', + ATTR_TARGET_HUMIDITY: 'target_humidity', + ATTR_LED_BRIGHTNESS: 'led_brightness', + ATTR_BUTTON_PRESSED: 'button_pressed', + ATTR_USE_TIME: 'use_time', + ATTR_HARDWARE_VERSION: 'hardware_version', +} + +AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA = { + **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER, + ATTR_SPEED: 'speed', + ATTR_DEPTH: 'depth', + ATTR_DRY: 'dry', +} + +OPERATION_MODES_AIRPURIFIER = ['Auto', 'Silent', 'Favorite', 'Idle'] +OPERATION_MODES_AIRPURIFIER_PRO = ['Auto', 'Silent', 'Favorite'] +OPERATION_MODES_AIRPURIFIER_V3 = ['Auto', 'Silent', 'Favorite', 'Idle', + 'Medium', 'High', 'Strong'] SUCCESS = ['ok'] +FEATURE_SET_BUZZER = 1 +FEATURE_SET_LED = 2 +FEATURE_SET_CHILD_LOCK = 4 +FEATURE_SET_LED_BRIGHTNESS = 8 +FEATURE_SET_FAVORITE_LEVEL = 16 +FEATURE_SET_AUTO_DETECT = 32 +FEATURE_SET_LEARN_MODE = 64 +FEATURE_SET_VOLUME = 128 +FEATURE_RESET_FILTER = 256 +FEATURE_SET_EXTRA_FEATURES = 512 +FEATURE_SET_TARGET_HUMIDITY = 1024 +FEATURE_SET_DRY = 2048 + +FEATURE_FLAGS_GENERIC = (FEATURE_SET_BUZZER | + FEATURE_SET_CHILD_LOCK) + +FEATURE_FLAGS_AIRPURIFIER = (FEATURE_FLAGS_GENERIC | + FEATURE_SET_LED | + FEATURE_SET_LED_BRIGHTNESS | + FEATURE_SET_FAVORITE_LEVEL | + FEATURE_SET_LEARN_MODE | + FEATURE_RESET_FILTER | + FEATURE_SET_EXTRA_FEATURES) + +FEATURE_FLAGS_AIRPURIFIER_PRO = (FEATURE_SET_CHILD_LOCK | + FEATURE_SET_LED | + FEATURE_SET_FAVORITE_LEVEL | + FEATURE_SET_AUTO_DETECT | + FEATURE_SET_VOLUME) + +FEATURE_FLAGS_AIRPURIFIER_V3 = (FEATURE_FLAGS_GENERIC | + FEATURE_SET_LED) + +FEATURE_FLAGS_AIRHUMIDIFIER = (FEATURE_FLAGS_GENERIC | + FEATURE_SET_LED_BRIGHTNESS | + FEATURE_SET_TARGET_HUMIDITY) + +FEATURE_FLAGS_AIRHUMIDIFIER_CA = (FEATURE_FLAGS_AIRHUMIDIFIER | + FEATURE_SET_DRY) + SERVICE_SET_BUZZER_ON = 'xiaomi_miio_set_buzzer_on' SERVICE_SET_BUZZER_OFF = 'xiaomi_miio_set_buzzer_off' SERVICE_SET_LED_ON = 'xiaomi_miio_set_led_on' SERVICE_SET_LED_OFF = 'xiaomi_miio_set_led_off' SERVICE_SET_CHILD_LOCK_ON = 'xiaomi_miio_set_child_lock_on' SERVICE_SET_CHILD_LOCK_OFF = 'xiaomi_miio_set_child_lock_off' -SERVICE_SET_FAVORITE_LEVEL = 'xiaomi_miio_set_favorite_level' SERVICE_SET_LED_BRIGHTNESS = 'xiaomi_miio_set_led_brightness' +SERVICE_SET_FAVORITE_LEVEL = 'xiaomi_miio_set_favorite_level' +SERVICE_SET_AUTO_DETECT_ON = 'xiaomi_miio_set_auto_detect_on' +SERVICE_SET_AUTO_DETECT_OFF = 'xiaomi_miio_set_auto_detect_off' +SERVICE_SET_LEARN_MODE_ON = 'xiaomi_miio_set_learn_mode_on' +SERVICE_SET_LEARN_MODE_OFF = 'xiaomi_miio_set_learn_mode_off' +SERVICE_SET_VOLUME = 'xiaomi_miio_set_volume' +SERVICE_RESET_FILTER = 'xiaomi_miio_reset_filter' +SERVICE_SET_EXTRA_FEATURES = 'xiaomi_miio_set_extra_features' +SERVICE_SET_TARGET_HUMIDITY = 'xiaomi_miio_set_target_humidity' +SERVICE_SET_DRY_ON = 'xiaomi_miio_set_dry_on' +SERVICE_SET_DRY_OFF = 'xiaomi_miio_set_dry_off' AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, @@ -74,6 +267,21 @@ SERVICE_SCHEMA_FAVORITE_LEVEL = AIRPURIFIER_SERVICE_SCHEMA.extend({ vol.All(vol.Coerce(int), vol.Clamp(min=0, max=16)) }) +SERVICE_SCHEMA_VOLUME = AIRPURIFIER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_VOLUME): + vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100)) +}) + +SERVICE_SCHEMA_EXTRA_FEATURES = AIRPURIFIER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_FEATURES): + vol.All(vol.Coerce(int), vol.Range(min=0)) +}) + +SERVICE_SCHEMA_TARGET_HUMIDITY = AIRPURIFIER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_HUMIDITY): + vol.All(vol.Coerce(int), vol.In([30, 40, 50, 60, 70, 80])) +}) + SERVICE_TO_METHOD = { SERVICE_SET_BUZZER_ON: {'method': 'async_set_buzzer_on'}, SERVICE_SET_BUZZER_OFF: {'method': 'async_set_buzzer_off'}, @@ -81,59 +289,99 @@ SERVICE_TO_METHOD = { SERVICE_SET_LED_OFF: {'method': 'async_set_led_off'}, SERVICE_SET_CHILD_LOCK_ON: {'method': 'async_set_child_lock_on'}, SERVICE_SET_CHILD_LOCK_OFF: {'method': 'async_set_child_lock_off'}, - SERVICE_SET_FAVORITE_LEVEL: { - 'method': 'async_set_favorite_level', - 'schema': SERVICE_SCHEMA_FAVORITE_LEVEL}, + SERVICE_SET_AUTO_DETECT_ON: {'method': 'async_set_auto_detect_on'}, + SERVICE_SET_AUTO_DETECT_OFF: {'method': 'async_set_auto_detect_off'}, + SERVICE_SET_LEARN_MODE_ON: {'method': 'async_set_learn_mode_on'}, + SERVICE_SET_LEARN_MODE_OFF: {'method': 'async_set_learn_mode_off'}, + SERVICE_RESET_FILTER: {'method': 'async_reset_filter'}, SERVICE_SET_LED_BRIGHTNESS: { 'method': 'async_set_led_brightness', 'schema': SERVICE_SCHEMA_LED_BRIGHTNESS}, + SERVICE_SET_FAVORITE_LEVEL: { + 'method': 'async_set_favorite_level', + 'schema': SERVICE_SCHEMA_FAVORITE_LEVEL}, + SERVICE_SET_VOLUME: { + 'method': 'async_set_volume', + 'schema': SERVICE_SCHEMA_VOLUME}, + SERVICE_SET_EXTRA_FEATURES: { + 'method': 'async_set_extra_features', + 'schema': SERVICE_SCHEMA_EXTRA_FEATURES}, + SERVICE_SET_TARGET_HUMIDITY: { + 'method': 'async_set_target_humidity', + 'schema': SERVICE_SCHEMA_TARGET_HUMIDITY}, + SERVICE_SET_DRY_ON: {'method': 'async_set_dry_on'}, + SERVICE_SET_DRY_OFF: {'method': 'async_set_dry_off'}, } # pylint: disable=unused-argument -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the air purifier from config.""" - from miio import AirPurifier, DeviceException - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the miio fan device from config.""" + from miio import Device, DeviceException + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} host = config.get(CONF_HOST) name = config.get(CONF_NAME) token = config.get(CONF_TOKEN) + model = config.get(CONF_MODEL) _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + unique_id = None - try: + if model is None: + try: + miio_device = Device(host, token) + device_info = miio_device.info() + model = device_info.model + unique_id = "{}-{}".format(model, device_info.mac_address) + _LOGGER.info("%s %s %s detected", + model, + device_info.firmware_version, + device_info.hardware_version) + except DeviceException: + raise PlatformNotReady + + if model.startswith('zhimi.airpurifier.'): + from miio import AirPurifier air_purifier = AirPurifier(host, token) + device = XiaomiAirPurifier(name, air_purifier, model, unique_id) + elif model.startswith('zhimi.humidifier.'): + from miio import AirHumidifier + air_humidifier = AirHumidifier(host, token) + device = XiaomiAirHumidifier(name, air_humidifier, model, unique_id) + else: + _LOGGER.error( + 'Unsupported device found! Please create an issue at ' + 'https://github.com/syssi/xiaomi_airpurifier/issues ' + 'and provide the following data: %s', model) + return False - xiaomi_air_purifier = XiaomiAirPurifier(name, air_purifier) - hass.data[PLATFORM][host] = xiaomi_air_purifier - except DeviceException: - raise PlatformNotReady + hass.data[DATA_KEY][host] = device + async_add_devices([device], update_before_add=True) - async_add_devices([xiaomi_air_purifier], update_before_add=True) - - @asyncio.coroutine - def async_service_handler(service): + async def async_service_handler(service): """Map services to methods on XiaomiAirPurifier.""" method = SERVICE_TO_METHOD.get(service.service) params = {key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID} entity_ids = service.data.get(ATTR_ENTITY_ID) if entity_ids: - devices = [device for device in hass.data[PLATFORM].values() if + devices = [device for device in hass.data[DATA_KEY].values() if device.entity_id in entity_ids] else: - devices = hass.data[PLATFORM].values() + devices = hass.data[DATA_KEY].values() update_tasks = [] for device in devices: - yield from getattr(device, method['method'])(**params) + if not hasattr(device, method['method']): + continue + await getattr(device, method['method'])(**params) update_tasks.append(device.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) for air_purifier_service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[air_purifier_service].get( @@ -142,31 +390,22 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): DOMAIN, air_purifier_service, async_service_handler, schema=schema) -class XiaomiAirPurifier(FanEntity): - """Representation of a Xiaomi Air Purifier.""" +class XiaomiGenericDevice(FanEntity): + """Representation of a generic Xiaomi device.""" - def __init__(self, name, air_purifier): - """Initialize the air purifier.""" + def __init__(self, name, device, model, unique_id): + """Initialize the generic Xiaomi device.""" self._name = name + self._device = device + self._model = model + self._unique_id = unique_id - self._air_purifier = air_purifier + self._available = False self._state = None self._state_attrs = { - ATTR_AIR_QUALITY_INDEX: None, - ATTR_TEMPERATURE: None, - ATTR_HUMIDITY: None, - ATTR_MODE: None, - ATTR_FILTER_HOURS_USED: None, - ATTR_FILTER_LIFE: None, - ATTR_FAVORITE_LEVEL: None, - ATTR_BUZZER: None, - ATTR_CHILD_LOCK: None, - ATTR_LED: None, - ATTR_LED_BRIGHTNESS: None, - ATTR_MOTOR_SPEED: None, - ATTR_AVERAGE_AIR_QUALITY_INDEX: None, - ATTR_PURIFY_VOLUME: None, + ATTR_MODEL: self._model, } + self._device_features = FEATURE_FLAGS_GENERIC self._skip_update = False @property @@ -176,9 +415,14 @@ class XiaomiAirPurifier(FanEntity): @property def should_poll(self): - """Poll the fan.""" + """Poll the device.""" return True + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + @property def name(self): """Return the name of the device if any.""" @@ -187,7 +431,7 @@ class XiaomiAirPurifier(FanEntity): @property def available(self): """Return true when state is known.""" - return self._state is not None + return self._available @property def device_state_attributes(self): @@ -196,50 +440,116 @@ class XiaomiAirPurifier(FanEntity): @property def is_on(self): - """Return true if fan is on.""" + """Return true if device is on.""" return self._state - @asyncio.coroutine - def _try_command(self, mask_error, func, *args, **kwargs): - """Call an air purifier command handling error messages.""" + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value + + async def _try_command(self, mask_error, func, *args, **kwargs): + """Call a miio device command handling error messages.""" from miio import DeviceException try: - result = yield from self.hass.async_add_job( + result = await self.hass.async_add_job( partial(func, *args, **kwargs)) - _LOGGER.debug("Response received from air purifier: %s", result) + _LOGGER.debug("Response received from miio device: %s", result) return result == SUCCESS except DeviceException as exc: _LOGGER.error(mask_error, exc) + self._available = False return False - @asyncio.coroutine - def async_turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: - """Turn the fan on.""" + async def async_turn_on(self, speed: str = None, + **kwargs) -> None: + """Turn the device on.""" if speed: # If operation mode was set the device must not be turned on. - result = yield from self.async_set_speed(speed) + result = await self.async_set_speed(speed) else: - result = yield from self._try_command( - "Turning the air purifier on failed.", self._air_purifier.on) + result = await self._try_command( + "Turning the miio device on failed.", self._device.on) if result: self._state = True self._skip_update = True - @asyncio.coroutine - def async_turn_off(self: ToggleEntity, **kwargs) -> None: - """Turn the fan off.""" - result = yield from self._try_command( - "Turning the air purifier off failed.", self._air_purifier.off) + async def async_turn_off(self, **kwargs) -> None: + """Turn the device off.""" + result = await self._try_command( + "Turning the miio device off failed.", self._device.off) if result: self._state = False self._skip_update = True - @asyncio.coroutine - def async_update(self): + async def async_set_buzzer_on(self): + """Turn the buzzer on.""" + if self._device_features & FEATURE_SET_BUZZER == 0: + return + + await self._try_command( + "Turning the buzzer of the miio device on failed.", + self._device.set_buzzer, True) + + async def async_set_buzzer_off(self): + """Turn the buzzer off.""" + if self._device_features & FEATURE_SET_BUZZER == 0: + return + + await self._try_command( + "Turning the buzzer of the miio device off failed.", + self._device.set_buzzer, False) + + async def async_set_child_lock_on(self): + """Turn the child lock on.""" + if self._device_features & FEATURE_SET_CHILD_LOCK == 0: + return + + await self._try_command( + "Turning the child lock of the miio device on failed.", + self._device.set_child_lock, True) + + async def async_set_child_lock_off(self): + """Turn the child lock off.""" + if self._device_features & FEATURE_SET_CHILD_LOCK == 0: + return + + await self._try_command( + "Turning the child lock of the miio device off failed.", + self._device.set_child_lock, False) + + +class XiaomiAirPurifier(XiaomiGenericDevice): + """Representation of a Xiaomi Air Purifier.""" + + def __init__(self, name, device, model, unique_id): + """Initialize the plug switch.""" + super().__init__(name, device, model, unique_id) + + if self._model == MODEL_AIRPURIFIER_PRO: + self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO + self._speed_list = OPERATION_MODES_AIRPURIFIER_PRO + elif self._model == MODEL_AIRPURIFIER_V3: + self._device_features = FEATURE_FLAGS_AIRPURIFIER_V3 + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 + self._speed_list = OPERATION_MODES_AIRPURIFIER_V3 + else: + self._device_features = FEATURE_FLAGS_AIRPURIFIER + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER + self._speed_list = OPERATION_MODES_AIRPURIFIER + + self._state_attrs.update( + {attribute: None for attribute in self._available_attributes}) + + async def async_update(self): """Fetch state from the device.""" from miio import DeviceException @@ -249,40 +559,24 @@ class XiaomiAirPurifier(FanEntity): return try: - state = yield from self.hass.async_add_job( - self._air_purifier.status) + state = await self.hass.async_add_job( + self._device.status) _LOGGER.debug("Got new state: %s", state) + self._available = True self._state = state.is_on - self._state_attrs = { - ATTR_TEMPERATURE: state.temperature, - ATTR_HUMIDITY: state.humidity, - ATTR_AIR_QUALITY_INDEX: state.aqi, - ATTR_MODE: state.mode.value, - ATTR_FILTER_HOURS_USED: state.filter_hours_used, - ATTR_FILTER_LIFE: state.filter_life_remaining, - ATTR_FAVORITE_LEVEL: state.favorite_level, - ATTR_BUZZER: state.buzzer, - ATTR_CHILD_LOCK: state.child_lock, - ATTR_LED: state.led, - ATTR_MOTOR_SPEED: state.motor_speed, - ATTR_AVERAGE_AIR_QUALITY_INDEX: state.average_aqi, - ATTR_PURIFY_VOLUME: state.purify_volume, - } - - if state.led_brightness: - self._state_attrs[ - ATTR_LED_BRIGHTNESS] = state.led_brightness.value + self._state_attrs.update( + {key: self._extract_value_from_attribute(state, value) for + key, value in self._available_attributes.items()}) except DeviceException as ex: - self._state = None + self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) @property - def speed_list(self: ToggleEntity) -> list: + def speed_list(self) -> list: """Get the list of available speeds.""" - from miio.airpurifier import OperationMode - return [mode.name for mode in OperationMode] + return self._speed_list @property def speed(self): @@ -294,70 +588,228 @@ class XiaomiAirPurifier(FanEntity): return None - @asyncio.coroutine - def async_set_speed(self: ToggleEntity, speed: str) -> None: + async def async_set_speed(self, speed: str) -> None: """Set the speed of the fan.""" - _LOGGER.debug("Setting the operation mode to: %s", speed) + if self.supported_features & SUPPORT_SET_SPEED == 0: + return + from miio.airpurifier import OperationMode - yield from self._try_command( - "Setting operation mode of the air purifier failed.", - self._air_purifier.set_mode, OperationMode[speed.title()]) + _LOGGER.debug("Setting the operation mode to: %s", speed) - @asyncio.coroutine - def async_set_buzzer_on(self): - """Turn the buzzer on.""" - yield from self._try_command( - "Turning the buzzer of the air purifier on failed.", - self._air_purifier.set_buzzer, True) + await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_mode, OperationMode[speed.title()]) - @asyncio.coroutine - def async_set_buzzer_off(self): - """Turn the buzzer off.""" - yield from self._try_command( - "Turning the buzzer of the air purifier off failed.", - self._air_purifier.set_buzzer, False) - - @asyncio.coroutine - def async_set_led_on(self): + async def async_set_led_on(self): """Turn the led on.""" - yield from self._try_command( - "Turning the led of the air purifier off failed.", - self._air_purifier.set_led, True) + if self._device_features & FEATURE_SET_LED == 0: + return - @asyncio.coroutine - def async_set_led_off(self): + await self._try_command( + "Turning the led of the miio device off failed.", + self._device.set_led, True) + + async def async_set_led_off(self): """Turn the led off.""" - yield from self._try_command( - "Turning the led of the air purifier off failed.", - self._air_purifier.set_led, False) + if self._device_features & FEATURE_SET_LED == 0: + return - @asyncio.coroutine - def async_set_child_lock_on(self): - """Turn the child lock on.""" - yield from self._try_command( - "Turning the child lock of the air purifier on failed.", - self._air_purifier.set_child_lock, True) + await self._try_command( + "Turning the led of the miio device off failed.", + self._device.set_led, False) - @asyncio.coroutine - def async_set_child_lock_off(self): - """Turn the child lock off.""" - yield from self._try_command( - "Turning the child lock of the air purifier off failed.", - self._air_purifier.set_child_lock, False) - - @asyncio.coroutine - def async_set_led_brightness(self, brightness: int = 2): + async def async_set_led_brightness(self, brightness: int = 2): """Set the led brightness.""" + if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: + return + from miio.airpurifier import LedBrightness - yield from self._try_command( - "Setting the led brightness of the air purifier failed.", - self._air_purifier.set_led_brightness, LedBrightness(brightness)) + await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, LedBrightness(brightness)) - @asyncio.coroutine - def async_set_favorite_level(self, level: int = 1): + async def async_set_favorite_level(self, level: int = 1): """Set the favorite level.""" - yield from self._try_command( - "Setting the favorite level of the air purifier failed.", - self._air_purifier.set_favorite_level, level) + if self._device_features & FEATURE_SET_FAVORITE_LEVEL == 0: + return + + await self._try_command( + "Setting the favorite level of the miio device failed.", + self._device.set_favorite_level, level) + + async def async_set_auto_detect_on(self): + """Turn the auto detect on.""" + if self._device_features & FEATURE_SET_AUTO_DETECT == 0: + return + + await self._try_command( + "Turning the auto detect of the miio device on failed.", + self._device.set_auto_detect, True) + + async def async_set_auto_detect_off(self): + """Turn the auto detect off.""" + if self._device_features & FEATURE_SET_AUTO_DETECT == 0: + return + + await self._try_command( + "Turning the auto detect of the miio device off failed.", + self._device.set_auto_detect, False) + + async def async_set_learn_mode_on(self): + """Turn the learn mode on.""" + if self._device_features & FEATURE_SET_LEARN_MODE == 0: + return + + await self._try_command( + "Turning the learn mode of the miio device on failed.", + self._device.set_learn_mode, True) + + async def async_set_learn_mode_off(self): + """Turn the learn mode off.""" + if self._device_features & FEATURE_SET_LEARN_MODE == 0: + return + + await self._try_command( + "Turning the learn mode of the miio device off failed.", + self._device.set_learn_mode, False) + + async def async_set_volume(self, volume: int = 50): + """Set the sound volume.""" + if self._device_features & FEATURE_SET_VOLUME == 0: + return + + await self._try_command( + "Setting the sound volume of the miio device failed.", + self._device.set_volume, volume) + + async def async_set_extra_features(self, features: int = 1): + """Set the extra features.""" + if self._device_features & FEATURE_SET_EXTRA_FEATURES == 0: + return + + await self._try_command( + "Setting the extra features of the miio device failed.", + self._device.set_extra_features, features) + + async def async_reset_filter(self): + """Reset the filter lifetime and usage.""" + if self._device_features & FEATURE_RESET_FILTER == 0: + return + + await self._try_command( + "Resetting the filter lifetime of the miio device failed.", + self._device.reset_filter) + + +class XiaomiAirHumidifier(XiaomiGenericDevice): + """Representation of a Xiaomi Air Humidifier.""" + + def __init__(self, name, device, model, unique_id): + """Initialize the plug switch.""" + from miio.airhumidifier import OperationMode + + super().__init__(name, device, model, unique_id) + + if self._model == MODEL_AIRHUMIDIFIER_CA: + self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA + self._speed_list = [mode.name for mode in OperationMode] + else: + self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER + self._speed_list = [mode.name for mode in OperationMode if + mode.name != 'Auto'] + + self._state_attrs.update( + {attribute: None for attribute in self._available_attributes}) + + async def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + + try: + state = await self.hass.async_add_job(self._device.status) + _LOGGER.debug("Got new state: %s", state) + + self._available = True + self._state = state.is_on + self._state_attrs.update( + {key: self._extract_value_from_attribute(state, value) for + key, value in self._available_attributes.items()}) + + except DeviceException as ex: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return self._speed_list + + @property + def speed(self): + """Return the current speed.""" + if self._state: + from miio.airhumidifier import OperationMode + + return OperationMode(self._state_attrs[ATTR_MODE]).name + + return None + + async def async_set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + if self.supported_features & SUPPORT_SET_SPEED == 0: + return + + from miio.airhumidifier import OperationMode + + _LOGGER.debug("Setting the operation mode to: %s", speed) + + await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_mode, OperationMode[speed.title()]) + + async def async_set_led_brightness(self, brightness: int = 2): + """Set the led brightness.""" + if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: + return + + from miio.airhumidifier import LedBrightness + + await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, LedBrightness(brightness)) + + async def async_set_target_humidity(self, humidity: int = 40): + """Set the target humidity.""" + if self._device_features & FEATURE_SET_TARGET_HUMIDITY == 0: + return + + await self._try_command( + "Setting the target humidity of the miio device failed.", + self._device.set_target_humidity, humidity) + + async def async_set_dry_on(self): + """Turn the dry mode on.""" + if self._device_features & FEATURE_SET_DRY == 0: + return + + await self._try_command( + "Turning the dry mode of the miio device off failed.", + self._device.set_dry, True) + + async def async_set_dry_off(self): + """Turn the dry mode off.""" + if self._device_features & FEATURE_SET_DRY == 0: + return + + await self._try_command( + "Turning the dry mode of the miio device off failed.", + self._device.set_dry, False) diff --git a/homeassistant/components/fan/zha.py b/homeassistant/components/fan/zha.py new file mode 100644 index 00000000000..01b1d0a92cf --- /dev/null +++ b/homeassistant/components/fan/zha.py @@ -0,0 +1,113 @@ +""" +Fans on Zigbee Home Automation networks. + +For more details on this platform, please refer to the documentation +at https://home-assistant.io/components/fan.zha/ +""" +import asyncio +import logging +from homeassistant.components import zha +from homeassistant.components.fan import ( + DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, + SUPPORT_SET_SPEED) + +DEPENDENCIES = ['zha'] + +_LOGGER = logging.getLogger(__name__) + +# Additional speeds in zigbee's ZCL +# Spec is unclear as to what this value means. On King Of Fans HBUniversal +# receiver, this means Very High. +SPEED_ON = 'on' +# The fan speed is self-regulated +SPEED_AUTO = 'auto' +# When the heated/cooled space is occupied, the fan is always on +SPEED_SMART = 'smart' + +SPEED_LIST = [ + SPEED_OFF, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_HIGH, + SPEED_ON, + SPEED_AUTO, + SPEED_SMART +] + +VALUE_TO_SPEED = {i: speed for i, speed in enumerate(SPEED_LIST)} +SPEED_TO_VALUE = {speed: i for i, speed in enumerate(SPEED_LIST)} + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Zigbee Home Automation fans.""" + discovery_info = zha.get_discovery_info(hass, discovery_info) + if discovery_info is None: + return + + async_add_devices([ZhaFan(**discovery_info)], update_before_add=True) + + +class ZhaFan(zha.Entity, FanEntity): + """Representation of a ZHA fan.""" + + _domain = DOMAIN + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return SPEED_LIST + + @property + def speed(self) -> str: + """Return the current speed.""" + return self._state + + @property + def is_on(self) -> bool: + """Return true if entity is on.""" + if self._state is None: + return False + return self._state != SPEED_OFF + + @asyncio.coroutine + def async_turn_on(self, speed: str = None, **kwargs) -> None: + """Turn the entity on.""" + if speed is None: + speed = SPEED_MEDIUM + + yield from self.async_set_speed(speed) + + @asyncio.coroutine + def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + yield from self.async_set_speed(SPEED_OFF) + + @asyncio.coroutine + def async_set_speed(self: FanEntity, speed: str) -> None: + """Set the speed of the fan.""" + yield from self._endpoint.fan.write_attributes({ + 'fan_mode': SPEED_TO_VALUE[speed]}) + + self._state = speed + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_update(self): + """Retrieve latest state.""" + result = yield from zha.safe_read(self._endpoint.fan, ['fan_mode']) + new_value = result.get('fan_mode', None) + self._state = VALUE_TO_SPEED.get(new_value, None) + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False diff --git a/homeassistant/components/feedreader.py b/homeassistant/components/feedreader.py index 2c0e146491a..73ab9e8123c 100644 --- a/homeassistant/components/feedreader.py +++ b/homeassistant/components/feedreader.py @@ -4,7 +4,7 @@ Support for RSS/Atom feeds. For more details about this component, please refer to the documentation at https://home-assistant.io/components/feedreader/ """ -from datetime import datetime +from datetime import datetime, timedelta from logging import getLogger from os.path import exists from threading import Lock @@ -12,8 +12,8 @@ import pickle import voluptuous as vol -from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.helpers.event import track_utc_time_change +from homeassistant.const import EVENT_HOMEASSISTANT_START, CONF_SCAN_INTERVAL +from homeassistant.helpers.event import track_time_interval import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['feedparser==5.2.1'] @@ -21,16 +21,22 @@ REQUIREMENTS = ['feedparser==5.2.1'] _LOGGER = getLogger(__name__) CONF_URLS = 'urls' +CONF_MAX_ENTRIES = 'max_entries' + +DEFAULT_MAX_ENTRIES = 20 +DEFAULT_SCAN_INTERVAL = timedelta(hours=1) DOMAIN = 'feedreader' EVENT_FEEDREADER = 'feedreader' -MAX_ENTRIES = 20 - CONFIG_SCHEMA = vol.Schema({ DOMAIN: { vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]), + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES): + cv.positive_int } }, extra=vol.ALLOW_EXTRA) @@ -38,33 +44,50 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Set up the Feedreader component.""" urls = config.get(DOMAIN)[CONF_URLS] + scan_interval = config.get(DOMAIN).get(CONF_SCAN_INTERVAL) + max_entries = config.get(DOMAIN).get(CONF_MAX_ENTRIES) data_file = hass.config.path("{}.pickle".format(DOMAIN)) storage = StoredData(data_file) - feeds = [FeedManager(url, hass, storage) for url in urls] + feeds = [FeedManager(url, scan_interval, max_entries, hass, storage) for + url in urls] return len(feeds) > 0 class FeedManager(object): """Abstraction over Feedparser module.""" - def __init__(self, url, hass, storage): - """Initialize the FeedManager object, poll every hour.""" + def __init__(self, url, scan_interval, max_entries, hass, storage): + """Initialize the FeedManager object, poll as per scan interval.""" self._url = url + self._scan_interval = scan_interval + self._max_entries = max_entries self._feed = None self._hass = hass self._firstrun = True self._storage = storage self._last_entry_timestamp = None + self._last_update_successful = False self._has_published_parsed = False + self._event_type = EVENT_FEEDREADER + self._feed_id = url hass.bus.listen_once( EVENT_HOMEASSISTANT_START, lambda _: self._update()) - track_utc_time_change( - hass, lambda now: self._update(), minute=0, second=0) + self._init_regular_updates(hass) def _log_no_entries(self): """Send no entries log at debug level.""" _LOGGER.debug("No new entries to be published in feed %s", self._url) + def _init_regular_updates(self, hass): + """Schedule regular updates at the top of the clock.""" + track_time_interval(hass, lambda now: self._update(), + self._scan_interval) + + @property + def last_update_successful(self): + """Return True if the last feed update was successful.""" + return self._last_update_successful + def _update(self): """Update the feed and publish new entries to the event bus.""" import feedparser @@ -76,26 +99,39 @@ class FeedManager(object): else self._feed.get('modified')) if not self._feed: _LOGGER.error("Error fetching feed data from %s", self._url) + self._last_update_successful = False else: + # The 'bozo' flag really only indicates that there was an issue + # during the initial parsing of the XML, but it doesn't indicate + # whether this is an unrecoverable error. In this case the + # feedparser lib is trying a less strict parsing approach. + # If an error is detected here, log error message but continue + # processing the feed entries if present. if self._feed.bozo != 0: - _LOGGER.error("Error parsing feed %s", self._url) + _LOGGER.error("Error parsing feed %s: %s", self._url, + self._feed.bozo_exception) # Using etag and modified, if there's no new data available, # the entries list will be empty - elif self._feed.entries: + if self._feed.entries: _LOGGER.debug("%s entri(es) available in feed %s", len(self._feed.entries), self._url) - if len(self._feed.entries) > MAX_ENTRIES: - _LOGGER.debug("Processing only the first %s entries " - "in feed %s", MAX_ENTRIES, self._url) - self._feed.entries = self._feed.entries[0:MAX_ENTRIES] + self._filter_entries() self._publish_new_entries() if self._has_published_parsed: self._storage.put_timestamp( - self._url, self._last_entry_timestamp) + self._feed_id, self._last_entry_timestamp) else: self._log_no_entries() + self._last_update_successful = True _LOGGER.info("Fetch from feed %s completed", self._url) + def _filter_entries(self): + """Filter the entries provided and return the ones to keep.""" + if len(self._feed.entries) > self._max_entries: + _LOGGER.debug("Processing only the first %s entries " + "in feed %s", self._max_entries, self._url) + self._feed.entries = self._feed.entries[0:self._max_entries] + def _update_and_fire_entry(self, entry): """Update last_entry_timestamp and fire entry.""" # We are lucky, `published_parsed` data available, let's make use of @@ -109,12 +145,12 @@ class FeedManager(object): _LOGGER.debug("No published_parsed info available for entry %s", entry.title) entry.update({'feed_url': self._url}) - self._hass.bus.fire(EVENT_FEEDREADER, entry) + self._hass.bus.fire(self._event_type, entry) def _publish_new_entries(self): """Publish new entries to the event bus.""" new_entries = False - self._last_entry_timestamp = self._storage.get_timestamp(self._url) + self._last_entry_timestamp = self._storage.get_timestamp(self._feed_id) if self._last_entry_timestamp: self._firstrun = False else: @@ -157,18 +193,18 @@ class StoredData(object): _LOGGER.error("Error loading data from pickled file %s", self._data_file) - def get_timestamp(self, url): - """Return stored timestamp for given url.""" + def get_timestamp(self, feed_id): + """Return stored timestamp for given feed id (usually the url).""" self._fetch_data() - return self._data.get(url) + return self._data.get(feed_id) - def put_timestamp(self, url, timestamp): - """Update timestamp for given URL.""" + def put_timestamp(self, feed_id, timestamp): + """Update timestamp for given feed id (usually the url).""" self._fetch_data() with self._lock, open(self._data_file, 'wb') as myfile: - self._data.update({url: timestamp}) + self._data.update({feed_id: timestamp}) _LOGGER.debug("Overwriting feed %s timestamp in storage file %s", - url, self._data_file) + feed_id, self._data_file) try: pickle.dump(self._data, myfile) except: # noqa: E722 # pylint: disable=bare-except diff --git a/homeassistant/components/folder_watcher.py b/homeassistant/components/folder_watcher.py new file mode 100644 index 00000000000..098b34ac948 --- /dev/null +++ b/homeassistant/components/folder_watcher.py @@ -0,0 +1,110 @@ +""" +Component for monitoring activity on a folder. + +For more details about this platform, refer to the documentation at +https://home-assistant.io/components/folder_watcher/ +""" +import os +import logging +import voluptuous as vol +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['watchdog==0.8.3'] +_LOGGER = logging.getLogger(__name__) + +CONF_FOLDER = 'folder' +CONF_PATTERNS = 'patterns' +DEFAULT_PATTERN = '*' +DOMAIN = "folder_watcher" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_FOLDER): cv.isdir, + vol.Optional(CONF_PATTERNS, default=[DEFAULT_PATTERN]): + vol.All(cv.ensure_list, [cv.string]), + })]) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the folder watcher.""" + conf = config[DOMAIN] + for watcher in conf: + path = watcher[CONF_FOLDER] + patterns = watcher[CONF_PATTERNS] + if not hass.config.is_allowed_path(path): + _LOGGER.error("folder %s is not valid or allowed", path) + return False + Watcher(path, patterns, hass) + + return True + + +def create_event_handler(patterns, hass): + """Return the Watchdog EventHandler object.""" + from watchdog.events import PatternMatchingEventHandler + + class EventHandler(PatternMatchingEventHandler): + """Class for handling Watcher events.""" + + def __init__(self, patterns, hass): + """Initialise the EventHandler.""" + super().__init__(patterns) + self.hass = hass + + def process(self, event): + """On Watcher event, fire HA event.""" + _LOGGER.debug("process(%s)", event) + if not event.is_directory: + folder, file_name = os.path.split(event.src_path) + self.hass.bus.fire( + DOMAIN, { + "event_type": event.event_type, + 'path': event.src_path, + 'file': file_name, + 'folder': folder, + }) + + def on_modified(self, event): + """File modified.""" + self.process(event) + + def on_moved(self, event): + """File moved.""" + self.process(event) + + def on_created(self, event): + """File created.""" + self.process(event) + + def on_deleted(self, event): + """File deleted.""" + self.process(event) + + return EventHandler(patterns, hass) + + +class Watcher(): + """Class for starting Watchdog.""" + + def __init__(self, path, patterns, hass): + """Initialise the watchdog observer.""" + from watchdog.observers import Observer + self._observer = Observer() + self._observer.schedule( + create_event_handler(patterns, hass), + path, + recursive=True) + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.startup) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) + + def startup(self, event): + """Start the watcher.""" + self._observer.start() + + def shutdown(self, event): + """Shutdown the watcher.""" + self._observer.stop() + self._observer.join() diff --git a/homeassistant/components/freedns.py b/homeassistant/components/freedns.py new file mode 100644 index 00000000000..0512030bdcb --- /dev/null +++ b/homeassistant/components/freedns.py @@ -0,0 +1,103 @@ +""" +Integrate with FreeDNS Dynamic DNS service at freedns.afraid.org. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/freedns/ +""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.const import (CONF_URL, CONF_ACCESS_TOKEN) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'freedns' + +DEFAULT_INTERVAL = timedelta(minutes=10) + +TIMEOUT = 10 +UPDATE_URL = 'https://freedns.afraid.org/dynamic/update.php' + +CONF_UPDATE_INTERVAL = 'update_interval' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Exclusive(CONF_URL, DOMAIN): cv.string, + vol.Exclusive(CONF_ACCESS_TOKEN, DOMAIN): cv.string, + vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta), + + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the FreeDNS component.""" + url = config[DOMAIN].get(CONF_URL) + auth_token = config[DOMAIN].get(CONF_ACCESS_TOKEN) + update_interval = config[DOMAIN].get(CONF_UPDATE_INTERVAL) + + session = hass.helpers.aiohttp_client.async_get_clientsession() + + result = yield from _update_freedns( + hass, session, url, auth_token) + + if result is False: + return False + + @asyncio.coroutine + def update_domain_callback(now): + """Update the FreeDNS entry.""" + yield from _update_freedns(hass, session, url, auth_token) + + hass.helpers.event.async_track_time_interval( + update_domain_callback, update_interval) + + return True + + +@asyncio.coroutine +def _update_freedns(hass, session, url, auth_token): + """Update FreeDNS.""" + params = None + + if url is None: + url = UPDATE_URL + + if auth_token is not None: + params = {} + params[auth_token] = "" + + try: + with async_timeout.timeout(TIMEOUT, loop=hass.loop): + resp = yield from session.get(url, params=params) + body = yield from resp.text() + + if "has not changed" in body: + # IP has not changed. + _LOGGER.debug("FreeDNS update skipped: IP has not changed") + return True + + if "ERROR" not in body: + _LOGGER.debug("Updating FreeDNS was successful: %s", body) + return True + + if "Invalid update URL" in body: + _LOGGER.error("FreeDNS update token is invalid") + else: + _LOGGER.warning("Updating FreeDNS failed: %s", body) + + except aiohttp.ClientError: + _LOGGER.warning("Can't connect to FreeDNS API") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from FreeDNS API at %s", url) + + return False diff --git a/homeassistant/components/fritzbox.py b/homeassistant/components/fritzbox.py new file mode 100755 index 00000000000..a3c35aaa597 --- /dev/null +++ b/homeassistant/components/fritzbox.py @@ -0,0 +1,83 @@ +""" +Support for AVM Fritz!Box smarthome devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/fritzbox/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import discovery + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['pyfritzhome==0.3.7'] + +SUPPORTED_DOMAINS = ['climate', 'switch'] + +DOMAIN = 'fritzbox' + +ATTR_STATE_DEVICE_LOCKED = 'device_locked' +ATTR_STATE_LOCKED = 'locked' +ATTR_STATE_BATTERY_LOW = 'battery_low' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICES): + vol.All(cv.ensure_list, [ + vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + }), + ]), + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the fritzbox component.""" + from pyfritzhome import Fritzhome, LoginError + + fritz_list = [] + + configured_devices = config[DOMAIN].get(CONF_DEVICES) + for device in configured_devices: + host = device.get(CONF_HOST) + username = device.get(CONF_USERNAME) + password = device.get(CONF_PASSWORD) + fritzbox = Fritzhome(host=host, user=username, + password=password) + try: + fritzbox.login() + _LOGGER.info("Connected to device %s", device) + except LoginError: + _LOGGER.warning("Login to Fritz!Box %s as %s failed", + host, username) + continue + + fritz_list.append(fritzbox) + + if not fritz_list: + _LOGGER.info("No fritzboxes configured") + return False + + hass.data[DOMAIN] = fritz_list + + def logout_fritzboxes(event): + """Close all connections to the fritzboxes.""" + for fritz in fritz_list: + fritz.logout() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout_fritzboxes) + + for domain in SUPPORTED_DOMAINS: + discovery.load_platform(hass, domain, DOMAIN, {}, config) + + return True diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 1f5a7576302..5dad77f64ce 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -16,15 +16,16 @@ import voluptuous as vol import jinja2 import homeassistant.helpers.config_validation as cv -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.http.const import KEY_AUTHENTICATED +from homeassistant.components import websocket_api from homeassistant.config import find_config_file, load_yaml_config_file from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180310.0'] +REQUIREMENTS = ['home-assistant-frontend==20180603.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] @@ -94,6 +95,10 @@ SERVICE_RELOAD_THEMES = 'reload_themes' SERVICE_SET_THEME_SCHEMA = vol.Schema({ vol.Required(CONF_NAME): cv.string, }) +WS_TYPE_GET_PANELS = 'get_panels' +SCHEMA_GET_PANELS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_GET_PANELS, +}) class AbstractPanel: @@ -142,21 +147,6 @@ class AbstractPanel: 'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path), index_view.get) - def to_response(self, hass, request): - """Panel as dictionary.""" - result = { - 'component_name': self.component_name, - 'icon': self.sidebar_icon, - 'title': self.sidebar_title, - 'url_path': self.frontend_url_path, - 'config': self.config, - } - if _is_latest(hass.data[DATA_JS_VERSION], request): - result['url'] = self.webcomponent_url_latest - else: - result['url'] = self.webcomponent_url_es5 - return result - class BuiltInPanel(AbstractPanel): """Panel that is part of hass_frontend.""" @@ -170,30 +160,15 @@ class BuiltInPanel(AbstractPanel): self.frontend_url_path = frontend_url_path or component_name self.config = config - @asyncio.coroutine - def async_finalize(self, hass, frontend_repository_path): - """Finalize this panel for usage. - - If frontend_repository_path is set, will be prepended to path of - built-in components. - """ - if frontend_repository_path is None: - import hass_frontend - import hass_frontend_es5 - - self.webcomponent_url_latest = \ - '/frontend_latest/panels/ha-panel-{}-{}.html'.format( - self.component_name, - hass_frontend.FINGERPRINTS[self.component_name]) - self.webcomponent_url_es5 = \ - '/frontend_es5/panels/ha-panel-{}-{}.html'.format( - self.component_name, - hass_frontend_es5.FINGERPRINTS[self.component_name]) - else: - # Dev mode - self.webcomponent_url_es5 = self.webcomponent_url_latest = \ - '/home-assistant-polymer/panels/{}/ha-panel-{}.html'.format( - self.component_name, self.component_name) + def to_response(self, hass, request): + """Panel as dictionary.""" + return { + 'component_name': self.component_name, + 'icon': self.sidebar_icon, + 'title': self.sidebar_title, + 'config': self.config, + 'url_path': self.frontend_url_path, + } class ExternalPanel(AbstractPanel): @@ -239,6 +214,21 @@ class ExternalPanel(AbstractPanel): frontend_repository_path is None) self.REGISTERED_COMPONENTS.add(self.component_name) + def to_response(self, hass, request): + """Panel as dictionary.""" + result = { + 'component_name': self.component_name, + 'icon': self.sidebar_icon, + 'title': self.sidebar_title, + 'url_path': self.frontend_url_path, + 'config': self.config, + } + if _is_latest(hass.data[DATA_JS_VERSION], request): + result['url'] = self.webcomponent_url_latest + else: + result['url'] = self.webcomponent_url_es5 + return result + @bind_hass @asyncio.coroutine @@ -291,6 +281,17 @@ def add_manifest_json_key(key, val): @asyncio.coroutine def async_setup(hass, config): """Set up the serving of the frontend.""" + if list(hass.auth.async_auth_providers): + client = yield from hass.auth.async_create_client( + 'Home Assistant Frontend', + redirect_uris=['/'], + no_secret=True, + ) + else: + client = None + + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_PANELS, websocket_handle_get_panels, SCHEMA_GET_PANELS) hass.http.register_view(ManifestJSONView) conf = config.get(DOMAIN, {}) @@ -300,59 +301,40 @@ def async_setup(hass, config): hass.data[DATA_JS_VERSION] = js_version = conf.get(CONF_JS_VERSION) if is_dev: - for subpath in ["src", "build-translations", "build-temp", "build", - "hass_frontend", "bower_components", "panels", - "hassio"]: - hass.http.register_static_path( - "/home-assistant-polymer/{}".format(subpath), - os.path.join(repo_path, subpath), - False) - - hass.http.register_static_path( - "/static/translations", - os.path.join(repo_path, "build-translations/output"), False) - sw_path_es5 = os.path.join(repo_path, "build-es5/service_worker.js") - sw_path_latest = os.path.join(repo_path, "build/service_worker.js") - static_path = os.path.join(repo_path, 'hass_frontend') - frontend_es5_path = os.path.join(repo_path, 'build-es5') - frontend_latest_path = os.path.join(repo_path, 'build') + hass_frontend_path = os.path.join(repo_path, 'hass_frontend') + hass_frontend_es5_path = os.path.join(repo_path, 'hass_frontend_es5') else: import hass_frontend import hass_frontend_es5 - sw_path_es5 = os.path.join(hass_frontend_es5.where(), - "service_worker.js") - sw_path_latest = os.path.join(hass_frontend.where(), - "service_worker.js") - # /static points to dir with files that are JS-type agnostic. - # ES5 files are served from /frontend_es5. - # ES6 files are served from /frontend_latest. - static_path = hass_frontend.where() - frontend_es5_path = hass_frontend_es5.where() - frontend_latest_path = static_path + hass_frontend_path = hass_frontend.where() + hass_frontend_es5_path = hass_frontend_es5.where() hass.http.register_static_path( - "/service_worker_es5.js", sw_path_es5, False) + "/service_worker_es5.js", + os.path.join(hass_frontend_es5_path, "service_worker.js"), False) hass.http.register_static_path( - "/service_worker.js", sw_path_latest, False) + "/service_worker.js", + os.path.join(hass_frontend_path, "service_worker.js"), False) hass.http.register_static_path( - "/robots.txt", os.path.join(static_path, "robots.txt"), not is_dev) - hass.http.register_static_path("/static", static_path, not is_dev) + "/robots.txt", + os.path.join(hass_frontend_path, "robots.txt"), False) + hass.http.register_static_path("/static", hass_frontend_path, not is_dev) hass.http.register_static_path( - "/frontend_latest", frontend_latest_path, not is_dev) + "/frontend_latest", hass_frontend_path, not is_dev) hass.http.register_static_path( - "/frontend_es5", frontend_es5_path, not is_dev) + "/frontend_es5", hass_frontend_es5_path, not is_dev) local = hass.config.path('www') if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(repo_path, js_version) + index_view = IndexView(repo_path, js_version, client) hass.http.register_view(index_view) - @asyncio.coroutine - def finalize_panel(panel): + async def finalize_panel(panel): """Finalize setup of a panel.""" - yield from panel.async_finalize(hass, repo_path) + if hasattr(panel, 'async_finalize'): + await panel.async_finalize(hass, repo_path) panel.async_register_index_routes(hass.http.app.router, index_view) yield from asyncio.wait([ @@ -444,10 +426,11 @@ class IndexView(HomeAssistantView): requires_auth = False extra_urls = ['/states', '/states/{extra}'] - def __init__(self, repo_path, js_option): + def __init__(self, repo_path, js_option, client): """Initialize the frontend view.""" self.repo_path = repo_path self.js_option = js_option + self.client = client self._template_cache = {} def get_template(self, latest): @@ -501,7 +484,7 @@ class IndexView(HomeAssistantView): extra_key = DATA_EXTRA_HTML_URL if latest else DATA_EXTRA_HTML_URL_ES5 - resp = template.render( + template_params = dict( no_auth=no_auth, panel_url=panel_url, panels=hass.data[DATA_PANELS], @@ -509,7 +492,11 @@ class IndexView(HomeAssistantView): extra_urls=hass.data[extra_key], ) - return web.Response(text=resp, content_type='text/html') + if self.client is not None: + template_params['client_id'] = self.client.id + + return web.Response(text=template.render(**template_params), + content_type='text/html') class ManifestJSONView(HomeAssistantView): @@ -597,3 +584,19 @@ def _is_latest(js_option, request): useragent = request.headers.get('User-Agent') return useragent and hass_frontend.version(useragent) + + +@callback +def websocket_handle_get_panels(hass, connection, msg): + """Handle get panels command. + + Async friendly. + """ + panels = { + panel: + connection.hass.data[DATA_PANELS][panel].to_response( + connection.hass, connection.request) + for panel in connection.hass.data[DATA_PANELS]} + + connection.to_write.put_nowait(websocket_api.result_message( + msg['id'], panels)) diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index 30151ee1a56..b41d4ea33a2 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -44,6 +44,7 @@ CONF_ENTITIES = 'entities' CONF_TRACK = 'track' CONF_SEARCH = 'search' CONF_OFFSET = 'offset' +CONF_IGNORE_AVAILABILITY = 'ignore_availability' DEFAULT_CONF_TRACK_NEW = True DEFAULT_CONF_OFFSET = '!!' @@ -74,8 +75,9 @@ _SINGLE_CALSEARCH_CONFIG = vol.Schema({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_DEVICE_ID): cv.string, vol.Optional(CONF_TRACK): cv.boolean, - vol.Optional(CONF_SEARCH): vol.Any(cv.string, None), + vol.Optional(CONF_SEARCH): cv.string, vol.Optional(CONF_OFFSET): cv.string, + vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean, }) DEVICE_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 676654c2c91..1c6d11a7c99 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -70,8 +70,7 @@ def request_sync(hass): hass.services.call(DOMAIN, SERVICE_REQUEST_SYNC) -@asyncio.coroutine -def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): +async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): """Activate Google Actions component.""" config = yaml_config.get(DOMAIN, {}) agent_user_id = config.get(CONF_AGENT_USER_ID) @@ -79,20 +78,19 @@ def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): hass.http.register_view(GoogleAssistantAuthView(hass, config)) async_register_http(hass, config) - @asyncio.coroutine - def request_sync_service_handler(call): + async def request_sync_service_handler(call): """Handle request sync service calls.""" websession = async_get_clientsession(hass) try: with async_timeout.timeout(5, loop=hass.loop): - res = yield from websession.post( + res = await websession.post( REQUEST_SYNC_BASE_URL, params={'key': api_key}, json={'agent_user_id': agent_user_id}) _LOGGER.info("Submitted request_sync request to Google") res.raise_for_status() except aiohttp.ClientResponseError: - body = yield from res.read() + body = await res.read() _LOGGER.error( 'request_sync request failed: %d %s', res.status, body) except (asyncio.TimeoutError, aiohttp.ClientError): diff --git a/homeassistant/components/google_assistant/auth.py b/homeassistant/components/google_assistant/auth.py index 1ed27403797..a21dd0e6738 100644 --- a/homeassistant/components/google_assistant/auth.py +++ b/homeassistant/components/google_assistant/auth.py @@ -1,6 +1,5 @@ """Google Assistant OAuth View.""" -import asyncio import logging # Typing imports @@ -44,8 +43,7 @@ class GoogleAssistantAuthView(HomeAssistantView): self.client_id = cfg.get(CONF_CLIENT_ID) self.access_token = cfg.get(CONF_ACCESS_TOKEN) - @asyncio.coroutine - def get(self, request: Request) -> Response: + async def get(self, request: Request) -> Response: """Handle oauth token request.""" query = request.query redirect_uri = query.get('redirect_uri') diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 0caea3aadf4..0ea5f7d9fa4 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -4,7 +4,6 @@ Support for Google Actions Smart Home Control. For more details about this component, please refer to the documentation at https://home-assistant.io/components/google_assistant/ """ -import asyncio import logging from aiohttp.hdrs import AUTHORIZATION @@ -77,14 +76,13 @@ class GoogleAssistantView(HomeAssistantView): self.access_token = access_token self.gass_config = gass_config - @asyncio.coroutine - def post(self, request: Request) -> Response: + async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" auth = request.headers.get(AUTHORIZATION, None) if 'Bearer {}'.format(self.access_token) != auth: return self.json_message("missing authorization", status_code=401) - message = yield from request.json() # type: dict - result = yield from async_handle_message( + message = await request.json() # type: dict + result = await async_handle_message( request.app['hass'], self.gass_config, message) return self.json(result) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 48d24c00b97..27d993aee76 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -17,7 +17,16 @@ from homeassistant.core import callback from homeassistant.const import ( CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES) from homeassistant.components import ( - switch, light, cover, media_player, group, fan, scene, script, climate, + climate, + cover, + fan, + group, + input_boolean, + light, + media_player, + scene, + script, + switch, ) from . import trait @@ -33,15 +42,16 @@ HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) DOMAIN_TO_GOOGLE_TYPES = { + climate.DOMAIN: TYPE_THERMOSTAT, + cover.DOMAIN: TYPE_SWITCH, + fan.DOMAIN: TYPE_SWITCH, group.DOMAIN: TYPE_SWITCH, + input_boolean.DOMAIN: TYPE_SWITCH, + light.DOMAIN: TYPE_LIGHT, + media_player.DOMAIN: TYPE_SWITCH, scene.DOMAIN: TYPE_SCENE, script.DOMAIN: TYPE_SCENE, switch.DOMAIN: TYPE_SWITCH, - fan.DOMAIN: TYPE_SWITCH, - light.DOMAIN: TYPE_LIGHT, - cover.DOMAIN: TYPE_SWITCH, - media_player.DOMAIN: TYPE_SWITCH, - climate.DOMAIN: TYPE_THERMOSTAT, } @@ -84,19 +94,31 @@ class _GoogleEntity: https://developers.google.com/actions/smarthome/create-app#actiondevicessync """ - traits = self.traits() state = self.state + # When a state is unavailable, the attributes that describe + # capabilities will be stripped. For example, a light entity will miss + # the min/max mireds. Therefore they will be excluded from a sync. + if state.state == STATE_UNAVAILABLE: + return None + + entity_config = self.config.entity_config.get(state.entity_id, {}) + name = (entity_config.get(CONF_NAME) or state.name).strip() + + # If an empty string + if not name: + return None + + traits = self.traits() + # Found no supported traits for this entity if not traits: return None - entity_config = self.config.entity_config.get(state.entity_id, {}) - device = { 'id': state.entity_id, 'name': { - 'name': entity_config.get(CONF_NAME) or state.name + 'name': name }, 'attributes': {}, 'traits': [trait.name for trait in traits], diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index c78d70e21e6..2f60f226042 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -243,7 +243,7 @@ class ColorSpectrumTrait(_Trait): if domain != light.DOMAIN: return False - return features & (light.SUPPORT_RGB_COLOR | light.SUPPORT_XY_COLOR) + return features & light.SUPPORT_COLOR def sync_attributes(self): """Return color spectrum attributes for a sync request.""" @@ -254,13 +254,11 @@ class ColorSpectrumTrait(_Trait): """Return color spectrum query attributes.""" response = {} - # No need to handle XY color because light component will always - # convert XY to RGB if possible (which is when brightness is available) - color_rgb = self.state.attributes.get(light.ATTR_RGB_COLOR) - if color_rgb is not None: + color_hs = self.state.attributes.get(light.ATTR_HS_COLOR) + if color_hs is not None: response['color'] = { 'spectrumRGB': int(color_util.color_rgb_to_hex( - color_rgb[0], color_rgb[1], color_rgb[2]), 16), + *color_util.color_hs_to_RGB(*color_hs)), 16), } return response @@ -274,11 +272,12 @@ class ColorSpectrumTrait(_Trait): """Execute a color spectrum command.""" # Convert integer to hex format and left pad with 0's till length 6 hex_value = "{0:06x}".format(params['color']['spectrumRGB']) - color = color_util.rgb_hex_to_rgb_list(hex_value) + color = color_util.color_RGB_to_hs( + *color_util.rgb_hex_to_rgb_list(hex_value)) await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id, - light.ATTR_RGB_COLOR: color + light.ATTR_HS_COLOR: color }, blocking=True) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 67ad8066aff..a33e91f3aa9 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_CLOSED, STATE_HOME, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_LOCKED, STATE_UNLOCKED, STATE_OK, STATE_PROBLEM, STATE_UNKNOWN, - ATTR_ASSUMED_STATE, SERVICE_RELOAD) + ATTR_ASSUMED_STATE, SERVICE_RELOAD, ATTR_NAME, ATTR_ICON) from homeassistant.core import callback from homeassistant.loader import bind_hass from homeassistant.helpers.entity import Entity, async_generate_entity_id @@ -35,8 +35,6 @@ ATTR_ADD_ENTITIES = 'add_entities' ATTR_AUTO = 'auto' ATTR_CONTROL = 'control' ATTR_ENTITIES = 'entities' -ATTR_ICON = 'icon' -ATTR_NAME = 'name' ATTR_OBJECT_ID = 'object_id' ATTR_ORDER = 'order' ATTR_VIEW = 'view' @@ -245,34 +243,31 @@ def get_entity_ids(hass, entity_id, domain_filter=None): if ent_id.startswith(domain_filter)] -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up all groups found defined in the configuration.""" component = hass.data.get(DOMAIN) if component is None: component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) - yield from _async_process_config(hass, config, component) + await _async_process_config(hass, config, component) - @asyncio.coroutine - def reload_service_handler(service): + async def reload_service_handler(service): """Remove all user-defined groups and load new ones from config.""" auto = list(filter(lambda e: not e.user_defined, component.entities)) - conf = yield from component.async_prepare_reload() + conf = await component.async_prepare_reload() if conf is None: return - yield from _async_process_config(hass, conf, component) + await _async_process_config(hass, conf, component) - yield from component.async_add_entities(auto) + await component.async_add_entities(auto) hass.services.async_register( DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=RELOAD_SERVICE_SCHEMA) - @asyncio.coroutine - def groups_service_handler(service): + async def groups_service_handler(service): """Handle dynamic group service functions.""" object_id = service.data[ATTR_OBJECT_ID] entity_id = ENTITY_ID_FORMAT.format(object_id) @@ -287,7 +282,7 @@ def async_setup(hass, config): ATTR_VISIBLE, ATTR_ICON, ATTR_VIEW, ATTR_CONTROL ) if service.data.get(attr) is not None} - yield from Group.async_create_group( + await Group.async_create_group( hass, service.data.get(ATTR_NAME, object_id), object_id=object_id, entity_ids=entity_ids, @@ -308,11 +303,11 @@ def async_setup(hass, config): if ATTR_ADD_ENTITIES in service.data: delta = service.data[ATTR_ADD_ENTITIES] entity_ids = set(group.tracking) | set(delta) - yield from group.async_update_tracked_entity_ids(entity_ids) + await group.async_update_tracked_entity_ids(entity_ids) if ATTR_ENTITIES in service.data: entity_ids = service.data[ATTR_ENTITIES] - yield from group.async_update_tracked_entity_ids(entity_ids) + await group.async_update_tracked_entity_ids(entity_ids) if ATTR_NAME in service.data: group.name = service.data[ATTR_NAME] @@ -335,13 +330,13 @@ def async_setup(hass, config): need_update = True if need_update: - yield from group.async_update_ha_state() + await group.async_update_ha_state() return # remove group if service.service == SERVICE_REMOVE: - yield from component.async_remove_entity(entity_id) + await component.async_remove_entity(entity_id) hass.services.async_register( DOMAIN, SERVICE_SET, groups_service_handler, @@ -351,8 +346,7 @@ def async_setup(hass, config): DOMAIN, SERVICE_REMOVE, groups_service_handler, schema=REMOVE_SERVICE_SCHEMA) - @asyncio.coroutine - def visibility_service_handler(service): + async def visibility_service_handler(service): """Change visibility of a group.""" visible = service.data.get(ATTR_VISIBLE) @@ -363,7 +357,7 @@ def async_setup(hass, config): tasks.append(group.async_update_ha_state()) if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_VISIBILITY, visibility_service_handler, @@ -372,8 +366,7 @@ def async_setup(hass, config): return True -@asyncio.coroutine -def _async_process_config(hass, config, component): +async def _async_process_config(hass, config, component): """Process group configuration.""" for object_id, conf in config.get(DOMAIN, {}).items(): name = conf.get(CONF_NAME, object_id) @@ -384,7 +377,7 @@ def _async_process_config(hass, config, component): # Don't create tasks and await them all. The order is important as # groups get a number based on creation order. - yield from Group.async_create_group( + await Group.async_create_group( hass, name, entity_ids, icon=icon, view=view, control=control, object_id=object_id) @@ -428,10 +421,9 @@ class Group(Entity): hass.loop).result() @staticmethod - @asyncio.coroutine - def async_create_group(hass, name, entity_ids=None, user_defined=True, - visible=True, icon=None, view=False, control=None, - object_id=None): + async def async_create_group(hass, name, entity_ids=None, + user_defined=True, visible=True, icon=None, + view=False, control=None, object_id=None): """Initialize a group. This method must be run in the event loop. @@ -453,7 +445,7 @@ class Group(Entity): component = hass.data[DOMAIN] = \ EntityComponent(_LOGGER, DOMAIN, hass) - yield from component.async_add_entities([group], True) + await component.async_add_entities([group], True) return group @@ -520,17 +512,16 @@ class Group(Entity): self.async_update_tracked_entity_ids(entity_ids), self.hass.loop ).result() - @asyncio.coroutine - def async_update_tracked_entity_ids(self, entity_ids): + async def async_update_tracked_entity_ids(self, entity_ids): """Update the member entity IDs. This method must be run in the event loop. """ - yield from self.async_stop() + await self.async_stop() self.tracking = tuple(ent_id.lower() for ent_id in entity_ids) self.group_on, self.group_off = None, None - yield from self.async_update_ha_state(True) + await self.async_update_ha_state(True) self.async_start() @callback @@ -544,8 +535,7 @@ class Group(Entity): self.hass, self.tracking, self._async_state_changed_listener ) - @asyncio.coroutine - def async_stop(self): + async def async_stop(self): """Unregister the group from Home Assistant. This method must be run in the event loop. @@ -554,27 +544,24 @@ class Group(Entity): self._async_unsub_state_changed() self._async_unsub_state_changed = None - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Query all members and determine current group state.""" self._state = STATE_UNKNOWN self._async_update_group_state() - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Callback when added to HASS.""" if self.tracking: self.async_start() - @asyncio.coroutine - def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self): """Callback when removed from HASS.""" if self._async_unsub_state_changed: self._async_unsub_state_changed() self._async_unsub_state_changed = None - @asyncio.coroutine - def _async_state_changed_listener(self, entity_id, old_state, new_state): + async def _async_state_changed_listener(self, entity_id, old_state, + new_state): """Respond to a member state changing. This method must be run in the event loop. @@ -584,7 +571,7 @@ class Group(Entity): return self._async_update_group_state(new_state) - yield from self.async_update_ha_state() + await self.async_update_ha_state() @property def _tracking_states(self): diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 540659273b3..45c35dcdd2a 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -13,12 +13,13 @@ import voluptuous as vol from homeassistant.components import SERVICE_CHECK_CONFIG from homeassistant.const import ( - SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) + ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) from homeassistant.core import DOMAIN as HASS_DOMAIN from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.loader import bind_hass from homeassistant.util.dt import utcnow + from .handler import HassIO from .http import HassIOView @@ -27,6 +28,15 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'hassio' DEPENDENCIES = ['http'] +CONF_FRONTEND_REPO = 'development_repo' + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN): vol.Schema({ + vol.Optional(CONF_FRONTEND_REPO): cv.isdir, + }), +}, extra=vol.ALLOW_EXTRA) + + DATA_HOMEASSISTANT_VERSION = 'hassio_hass_version' HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) @@ -47,7 +57,6 @@ ATTR_SNAPSHOT = 'snapshot' ATTR_ADDONS = 'addons' ATTR_FOLDERS = 'folders' ATTR_HOMEASSISTANT = 'homeassistant' -ATTR_NAME = 'name' ATTR_PASSWORD = 'password' SCHEMA_NO_DATA = vol.Schema({}) @@ -142,7 +151,13 @@ def async_setup(hass, config): try: host = os.environ['HASSIO'] except KeyError: - _LOGGER.error("No Hass.io supervisor detect") + _LOGGER.error("Missing HASSIO environment variable.") + return False + + try: + os.environ['HASSIO_TOKEN'] + except KeyError: + _LOGGER.error("Missing HASSIO_TOKEN environment variable.") return False websession = hass.helpers.aiohttp_client.async_get_clientsession() @@ -152,11 +167,18 @@ def async_setup(hass, config): _LOGGER.error("Not connected with Hass.io") return False + # This overrides the normal API call that would be forwarded + development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO) + if development_repo is not None: + hass.http.register_static_path( + '/api/hassio/app-es5', + os.path.join(development_repo, 'hassio/build-es5'), False) + hass.http.register_view(HassIOView(host, websession)) if 'frontend' in hass.config.components: yield from hass.components.frontend.async_register_built_in_panel( - 'hassio', 'Hass.io', 'mdi:access-point-network') + 'hassio', 'Hass.io', 'mdi:home-assistant') if 'http' in config: yield from hassio.update_hass_api(config['http']) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index a954aaccbd4..c3caf40ba62 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -33,7 +33,7 @@ def _api_bool(funct): def _api_data(funct): - """Return a api data.""" + """Return data of an api.""" @asyncio.coroutine def _wrapper(*argv, **kwargs): """Wrap function.""" diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 9dd6427ec38..bb4f8219a33 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -36,7 +36,7 @@ NO_TIMEOUT = { } NO_AUTH = { - re.compile(r'^app-(es5|latest)/(index|hassio-app).html$'), + re.compile(r'^app-(es5|latest)/.+$'), re.compile(r'^addons/[^/]*/logo$') } diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index 8e2464d0922..b5d64f48dc7 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -35,7 +35,7 @@ CONF_TYPES = 'types' ICON_UNKNOWN = 'mdi:help' ICON_AUDIO = 'mdi:speaker' ICON_PLAYER = 'mdi:play' -ICON_TUNER = 'mdi:nest-thermostat' +ICON_TUNER = 'mdi:radio' ICON_RECORDER = 'mdi:microphone' ICON_TV = 'mdi:television' ICONS_BY_TYPE = { diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index dd14bbf6811..c27e394ce28 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -4,7 +4,6 @@ Provide pre-made queries on top of the recorder component. For more details about this component, please refer to the documentation at https://home-assistant.io/components/history/ """ -import asyncio from collections import defaultdict from datetime import timedelta from itertools import groupby @@ -118,6 +117,30 @@ def state_changes_during_period(hass, start_time, end_time=None, return states_to_json(hass, states, start_time, entity_ids) +def get_last_state_changes(hass, number_of_states, entity_id): + """Return the last number_of_states.""" + from homeassistant.components.recorder.models import States + + start_time = dt_util.utcnow() + + with session_scope(hass=hass) as session: + query = session.query(States).filter( + (States.last_changed == States.last_updated)) + + if entity_id is not None: + query = query.filter_by(entity_id=entity_id.lower()) + + entity_ids = [entity_id] if entity_id is not None else None + + states = execute( + query.order_by(States.last_updated.desc()).limit(number_of_states)) + + return states_to_json(hass, reversed(states), + start_time, + entity_ids, + include_start_time_state=False) + + def get_states(hass, utc_point_in_time, entity_ids=None, run=None, filters=None): """Return the states at a specific point in time.""" @@ -235,22 +258,22 @@ def get_state(hass, utc_point_in_time, entity_id, run=None): return states[0] if states else None -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the history hooks.""" filters = Filters() - exclude = config[DOMAIN].get(CONF_EXCLUDE) + conf = config.get(DOMAIN, {}) + exclude = conf.get(CONF_EXCLUDE) if exclude: filters.excluded_entities = exclude.get(CONF_ENTITIES, []) filters.excluded_domains = exclude.get(CONF_DOMAINS, []) - include = config[DOMAIN].get(CONF_INCLUDE) + include = conf.get(CONF_INCLUDE) if include: filters.included_entities = include.get(CONF_ENTITIES, []) filters.included_domains = include.get(CONF_DOMAINS, []) - use_include_order = config[DOMAIN].get(CONF_ORDER) + use_include_order = conf.get(CONF_ORDER) hass.http.register_view(HistoryPeriodView(filters, use_include_order)) - yield from hass.components.frontend.async_register_built_in_panel( + await hass.components.frontend.async_register_built_in_panel( 'history', 'history', 'mdi:poll-box') return True @@ -268,8 +291,7 @@ class HistoryPeriodView(HomeAssistantView): self.filters = filters self.use_include_order = use_include_order - @asyncio.coroutine - def get(self, request, datetime=None): + async def get(self, request, datetime=None): """Return history over a period of time.""" timer_start = time.perf_counter() if datetime: @@ -305,10 +327,10 @@ class HistoryPeriodView(HomeAssistantView): hass = request.app['hass'] - result = yield from hass.async_add_job( + result = await hass.async_add_job( get_significant_states, hass, start_time, end_time, entity_ids, self.filters, include_start_time_state) - result = result.values() + result = list(result.values()) if _LOGGER.isEnabledFor(logging.DEBUG): elapsed = time.perf_counter() - timer_start _LOGGER.debug( @@ -318,7 +340,6 @@ class HistoryPeriodView(HomeAssistantView): # by any entities explicitly included in the configuration. if self.use_include_order: - result = list(result) sorted_result = [] for order_entity in self.filters.included_entities: for state_list in result: @@ -329,8 +350,7 @@ class HistoryPeriodView(HomeAssistantView): sorted_result.extend(result) result = sorted_result - response = yield from hass.async_add_job(self.json, result) - return response + return await hass.async_add_job(self.json, result) class Filters(object): diff --git a/homeassistant/components/history_graph.py b/homeassistant/components/history_graph.py index e6977d60c30..fa7d615dce2 100644 --- a/homeassistant/components/history_graph.py +++ b/homeassistant/components/history_graph.py @@ -4,7 +4,6 @@ Support to graphs card in the UI. For more details about this component, please refer to the documentation at https://home-assistant.io/components/history_graph/ """ -import asyncio import logging import voluptuous as vol @@ -39,8 +38,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Load graph configurations.""" component = EntityComponent( _LOGGER, DOMAIN, hass) @@ -51,7 +49,7 @@ def async_setup(hass, config): graph = HistoryGraphEntity(name, cfg) graphs.append(graph) - yield from component.async_add_entities(graphs) + await component.async_add_entities(graphs) return True diff --git a/homeassistant/components/hive.py b/homeassistant/components/hive.py index abe52ebe98a..aa662fc2fb6 100644 --- a/homeassistant/components/hive.py +++ b/homeassistant/components/hive.py @@ -12,7 +12,7 @@ from homeassistant.const import (CONF_PASSWORD, CONF_SCAN_INTERVAL, import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform -REQUIREMENTS = ['pyhiveapi==0.2.11'] +REQUIREMENTS = ['pyhiveapi==0.2.14'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'hive' @@ -44,6 +44,8 @@ class HiveSession: light = None sensor = None switch = None + weather = None + attributes = None def setup(hass, config): @@ -70,6 +72,8 @@ def setup(hass, config): session.hotwater = Pyhiveapi.Hotwater() session.light = Pyhiveapi.Light() session.switch = Pyhiveapi.Switch() + session.weather = Pyhiveapi.Weather() + session.attributes = Pyhiveapi.Attributes() hass.data[DATA_HIVE] = session for ha_type, hive_type in DEVICETYPES.items(): diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index ad70740536e..34372b8b6a8 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -3,154 +3,242 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/homekit/ """ -import asyncio +import ipaddress import logging -import re +from zlib import adler32 import voluptuous as vol +import homeassistant.components.cover as cover from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, CONF_PORT, - TEMP_CELSIUS, TEMP_FAHRENHEIT, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) -from homeassistant.components.climate import ( - SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) + ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, + CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, CONF_TYPE, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + TEMP_CELSIUS, TEMP_FAHRENHEIT) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.util import get_local_ip from homeassistant.util.decorator import Registry +from .const import ( + CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FEATURE_LIST, CONF_FILTER, + DEFAULT_AUTO_START, DEFAULT_PORT, DEVICE_CLASS_CO2, DEVICE_CLASS_PM25, + DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START, TYPE_OUTLET, TYPE_SWITCH) +from .util import ( + show_setup_message, validate_entity_config, validate_media_player_features) TYPES = Registry() _LOGGER = logging.getLogger(__name__) -_RE_VALID_PINCODE = r"^(\d{3}-\d{2}-\d{3})$" +REQUIREMENTS = ['HAP-python==2.2.2'] -DOMAIN = 'homekit' -REQUIREMENTS = ['HAP-python==1.1.7'] - -BRIDGE_NAME = 'Home Assistant' -CONF_PIN_CODE = 'pincode' - -HOMEKIT_FILE = '.homekit.state' - - -def valid_pin(value): - """Validate pin code value.""" - match = re.match(_RE_VALID_PINCODE, str(value).strip()) - if not match: - raise vol.Invalid("Pin must be in the format: '123-45-678'") - return match.group(0) +# #### Driver Status #### +STATUS_READY = 0 +STATUS_RUNNING = 1 +STATUS_STOPPED = 2 +STATUS_WAIT = 3 +SWITCH_TYPES = {TYPE_OUTLET: 'Outlet', + TYPE_SWITCH: 'Switch'} CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.All({ - vol.Optional(CONF_PORT, default=51826): vol.Coerce(int), - vol.Optional(CONF_PIN_CODE, default='123-45-678'): valid_pin, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_IP_ADDRESS): + vol.All(ipaddress.ip_address, cv.string), + vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): cv.boolean, + vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA, + vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, }) }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Setup the HomeKit component.""" - _LOGGER.debug("Begin setup HomeKit") + _LOGGER.debug('Begin setup HomeKit') conf = config[DOMAIN] - port = conf.get(CONF_PORT) - pin = str.encode(conf.get(CONF_PIN_CODE)) + port = conf[CONF_PORT] + ip_address = conf.get(CONF_IP_ADDRESS) + auto_start = conf[CONF_AUTO_START] + entity_filter = conf[CONF_FILTER] + entity_config = conf[CONF_ENTITY_CONFIG] - homekit = HomeKit(hass, port) - homekit.setup_bridge(pin) + homekit = HomeKit(hass, port, ip_address, entity_filter, entity_config) + await hass.async_add_job(homekit.setup) + + if auto_start: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, homekit.start) + return True + + def handle_homekit_service_start(service): + """Handle start HomeKit service call.""" + if homekit.status != STATUS_READY: + _LOGGER.warning( + 'HomeKit is not ready. Either it is already running or has ' + 'been stopped.') + return + homekit.start() + + hass.services.async_register(DOMAIN, SERVICE_HOMEKIT_START, + handle_homekit_service_start) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, homekit.start_driver) return True -def import_types(): - """Import all types from files in the HomeKit directory.""" - _LOGGER.debug("Import type files.") - # pylint: disable=unused-variable - from . import ( # noqa F401 - covers, security_systems, sensors, switches, thermostats) - - -def get_accessory(hass, state): +def get_accessory(hass, driver, state, aid, config): """Take state and return an accessory object if supported.""" - if state.domain == 'sensor': - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if unit == TEMP_CELSIUS or unit == TEMP_FAHRENHEIT: - _LOGGER.debug("Add \"%s\" as \"%s\"", - state.entity_id, 'TemperatureSensor') - return TYPES['TemperatureSensor'](hass, state.entity_id, - state.name) + if not aid: + _LOGGER.warning('The entitiy "%s" is not supported, since it ' + 'generates an invalid aid, please change it.', + state.entity_id) + return None - elif state.domain == 'cover': - # Only add covers that support set_cover_position - if state.attributes.get(ATTR_SUPPORTED_FEATURES) & 4: - _LOGGER.debug("Add \"%s\" as \"%s\"", - state.entity_id, 'Window') - return TYPES['Window'](hass, state.entity_id, state.name) + a_type = None + name = config.get(CONF_NAME, state.name) - elif state.domain == 'alarm_control_panel': - _LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, - 'SecuritySystem') - return TYPES['SecuritySystem'](hass, state.entity_id, state.name) + if state.domain == 'alarm_control_panel': + a_type = 'SecuritySystem' + + elif state.domain == 'binary_sensor' or state.domain == 'device_tracker': + a_type = 'BinarySensor' elif state.domain == 'climate': - support_auto = False - features = state.attributes.get(ATTR_SUPPORTED_FEATURES) - # Check if climate device supports auto mode - if (features & SUPPORT_TARGET_TEMPERATURE_HIGH) \ - and (features & SUPPORT_TARGET_TEMPERATURE_LOW): - support_auto = True - _LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, 'Thermostat') - return TYPES['Thermostat'](hass, state.entity_id, - state.name, support_auto) + a_type = 'Thermostat' - elif state.domain == 'switch' or state.domain == 'remote' \ - or state.domain == 'input_boolean': - _LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, 'Switch') - return TYPES['Switch'](hass, state.entity_id, state.name) + elif state.domain == 'cover': + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + device_class = state.attributes.get(ATTR_DEVICE_CLASS) - return None + if device_class == 'garage' and \ + features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): + a_type = 'GarageDoorOpener' + elif features & cover.SUPPORT_SET_POSITION: + a_type = 'WindowCovering' + elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): + a_type = 'WindowCoveringBasic' + + elif state.domain == 'fan': + a_type = 'Fan' + + elif state.domain == 'light': + a_type = 'Light' + + elif state.domain == 'lock': + a_type = 'Lock' + + elif state.domain == 'media_player': + feature_list = config.get(CONF_FEATURE_LIST) + if feature_list and \ + validate_media_player_features(state, feature_list): + a_type = 'MediaPlayer' + + elif state.domain == 'sensor': + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + if device_class == DEVICE_CLASS_TEMPERATURE or \ + unit in (TEMP_CELSIUS, TEMP_FAHRENHEIT): + a_type = 'TemperatureSensor' + elif device_class == DEVICE_CLASS_HUMIDITY and unit == '%': + a_type = 'HumiditySensor' + elif device_class == DEVICE_CLASS_PM25 \ + or DEVICE_CLASS_PM25 in state.entity_id: + a_type = 'AirQualitySensor' + elif device_class == DEVICE_CLASS_CO2 \ + or DEVICE_CLASS_CO2 in state.entity_id: + a_type = 'CarbonDioxideSensor' + elif device_class == DEVICE_CLASS_ILLUMINANCE or unit in ('lm', 'lx'): + a_type = 'LightSensor' + + elif state.domain == 'switch': + switch_type = config.get(CONF_TYPE, TYPE_SWITCH) + a_type = SWITCH_TYPES[switch_type] + + elif state.domain in ('automation', 'input_boolean', 'remote', 'script'): + a_type = 'Switch' + + if a_type is None: + return None + + _LOGGER.debug('Add "%s" as "%s"', state.entity_id, a_type) + return TYPES[a_type](hass, driver, name, state.entity_id, aid, config) + + +def generate_aid(entity_id): + """Generate accessory aid with zlib adler32.""" + aid = adler32(entity_id.encode('utf-8')) + if aid == 0 or aid == 1: + return None + return aid class HomeKit(): """Class to handle all actions between HomeKit and Home Assistant.""" - def __init__(self, hass, port): + def __init__(self, hass, port, ip_address, entity_filter, entity_config): """Initialize a HomeKit object.""" - self._hass = hass + self.hass = hass self._port = port + self._ip_address = ip_address + self._filter = entity_filter + self._config = entity_config + self.status = STATUS_READY + self.bridge = None self.driver = None - def setup_bridge(self, pin): - """Setup the bridge component to track all accessories.""" - from .accessories import HomeBridge - self.bridge = HomeBridge(BRIDGE_NAME, 'homekit.bridge', pin) + def setup(self): + """Setup bridge and accessory driver.""" + from .accessories import HomeBridge, HomeDriver - def start_driver(self, event): + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self.stop) + + ip_addr = self._ip_address or get_local_ip() + path = self.hass.config.path(HOMEKIT_FILE) + self.driver = HomeDriver(self.hass, address=ip_addr, + port=self._port, persist_file=path) + self.bridge = HomeBridge(self.hass, self.driver) + + def add_bridge_accessory(self, state): + """Try adding accessory to bridge if configured beforehand.""" + if not state or not self._filter(state.entity_id): + return + aid = generate_aid(state.entity_id) + conf = self._config.pop(state.entity_id, {}) + acc = get_accessory(self.hass, self.driver, state, aid, conf) + if acc is not None: + self.bridge.add_accessory(acc) + + def start(self, *args): """Start the accessory driver.""" - from pyhap.accessory_driver import AccessoryDriver - self._hass.bus.listen_once( - EVENT_HOMEASSISTANT_STOP, self.stop_driver) + if self.status != STATUS_READY: + return + self.status = STATUS_WAIT - import_types() - _LOGGER.debug("Start adding accessories.") - for state in self._hass.states.all(): - acc = get_accessory(self._hass, state) - if acc is not None: - self.bridge.add_accessory(acc) + # pylint: disable=unused-variable + from . import ( # noqa F401 + type_covers, type_fans, type_lights, type_locks, + type_media_players, type_security_systems, type_sensors, + type_switches, type_thermostats) - ip_address = get_local_ip() - path = self._hass.config.path(HOMEKIT_FILE) - self.driver = AccessoryDriver(self.bridge, self._port, - ip_address, path) - _LOGGER.debug("Driver started") - self.driver.start() + for state in self.hass.states.all(): + self.add_bridge_accessory(state) + self.driver.add_accessory(self.bridge) - def stop_driver(self, event): + if not self.driver.state.paired: + show_setup_message(self.hass, self.driver.state.pincode) + + _LOGGER.debug('Driver start') + self.hass.add_job(self.driver.start) + self.status = STATUS_RUNNING + + def stop(self, *args): """Stop the accessory driver.""" - _LOGGER.debug("Driver stop") - if self.driver is not None: - self.driver.stop() + if self.status != STATUS_RUNNING: + return + self.status = STATUS_STOPPED + + _LOGGER.debug('Driver stop') + self.hass.add_job(self.driver.stop) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 1cd94070289..1b0d5ce1be4 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -1,70 +1,132 @@ """Extend the basic Accessory and Bridge functions.""" +from datetime import timedelta +from functools import partial, wraps +from inspect import getmodule import logging -from pyhap.accessory import Accessory, Bridge, Category +from pyhap.accessory import Accessory, Bridge +from pyhap.accessory_driver import AccessoryDriver +from pyhap.const import CATEGORY_OTHER + +from homeassistant.const import __version__ +from homeassistant.core import callback as ha_callback +from homeassistant.core import split_entity_id +from homeassistant.helpers.event import ( + async_track_state_change, track_point_in_utc_time) +from homeassistant.util import dt as dt_util from .const import ( - SERV_ACCESSORY_INFO, SERV_BRIDGING_STATE, MANUFACTURER, - CHAR_MODEL, CHAR_MANUFACTURER, CHAR_NAME, CHAR_SERIAL_NUMBER) - + BRIDGE_MODEL, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, + DEBOUNCE_TIMEOUT, MANUFACTURER) +from .util import ( + show_setup_message, dismiss_setup_message) _LOGGER = logging.getLogger(__name__) -def set_accessory_info(acc, name, model, manufacturer=MANUFACTURER, - serial_number='0000'): - """Set the default accessory information.""" - service = acc.get_service(SERV_ACCESSORY_INFO) - service.get_characteristic(CHAR_NAME).set_value(name) - service.get_characteristic(CHAR_MODEL).set_value(model) - service.get_characteristic(CHAR_MANUFACTURER).set_value(manufacturer) - service.get_characteristic(CHAR_SERIAL_NUMBER).set_value(serial_number) +def debounce(func): + """Decorator function. Debounce callbacks form HomeKit.""" + @ha_callback + def call_later_listener(self, *args): + """Callback listener called from call_later.""" + debounce_params = self.debounce.pop(func.__name__, None) + if debounce_params: + self.hass.async_add_job(func, self, *debounce_params[1:]) + @wraps(func) + def wrapper(self, *args): + """Wrapper starts async timer.""" + debounce_params = self.debounce.pop(func.__name__, None) + if debounce_params: + debounce_params[0]() # remove listener + remove_listener = track_point_in_utc_time( + self.hass, partial(call_later_listener, self), + dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT)) + self.debounce[func.__name__] = (remove_listener, *args) + logger.debug('%s: Start %s timeout', self.entity_id, + func.__name__.replace('set_', '')) -def add_preload_service(acc, service, chars=None, opt_chars=None): - """Define and return a service to be available for the accessory.""" - from pyhap.loader import get_serv_loader, get_char_loader - service = get_serv_loader().get(service) - if chars: - chars = chars if isinstance(chars, list) else [chars] - for char_name in chars: - char = get_char_loader().get(char_name) - service.add_characteristic(char) - if opt_chars: - opt_chars = opt_chars if isinstance(opt_chars, list) else [opt_chars] - for opt_char_name in opt_chars: - opt_char = get_char_loader().get(opt_char_name) - service.add_opt_characteristic(opt_char) - acc.add_service(service) - return service - - -def override_properties(char, new_properties): - """Override characteristic property values.""" - char.properties.update(new_properties) + name = getmodule(func).__name__ + logger = logging.getLogger(name) + return wrapper class HomeAccessory(Accessory): - """Class to extend the Accessory class.""" + """Adapter class for Accessory.""" - def __init__(self, display_name, model, category='OTHER', **kwargs): + def __init__(self, hass, driver, name, entity_id, aid, config, + category=CATEGORY_OTHER): """Initialize a Accessory object.""" - super().__init__(display_name, **kwargs) - set_accessory_info(self, display_name, model) - self.category = getattr(Category, category, Category.OTHER) + super().__init__(driver, name, aid=aid) + model = split_entity_id(entity_id)[0].replace("_", " ").title() + self.set_info_service( + firmware_revision=__version__, manufacturer=MANUFACTURER, + model=model, serial_number=entity_id) + self.category = category + self.config = config + self.entity_id = entity_id + self.hass = hass + self.debounce = {} - def _set_services(self): - add_preload_service(self, SERV_ACCESSORY_INFO) + async def run(self): + """Method called by accessory after driver is started. + + Run inside the HAP-python event loop. + """ + state = self.hass.states.get(self.entity_id) + self.hass.add_job(self.update_state_callback, None, None, state) + async_track_state_change( + self.hass, self.entity_id, self.update_state_callback) + + @ha_callback + def update_state_callback(self, entity_id=None, old_state=None, + new_state=None): + """Callback from state change listener.""" + _LOGGER.debug('New_state: %s', new_state) + if new_state is None: + return + self.hass.async_add_job(self.update_state, new_state) + + def update_state(self, new_state): + """Method called on state change to update HomeKit value. + + Overridden by accessory types. + """ + raise NotImplementedError() class HomeBridge(Bridge): - """Class to extend the Bridge class.""" + """Adapter class for Bridge.""" - def __init__(self, display_name, model, pincode, **kwargs): + def __init__(self, hass, driver, name=BRIDGE_NAME): """Initialize a Bridge object.""" - super().__init__(display_name, pincode=pincode, **kwargs) - set_accessory_info(self, display_name, model) + super().__init__(driver, name) + self.set_info_service( + firmware_revision=__version__, manufacturer=MANUFACTURER, + model=BRIDGE_MODEL, serial_number=BRIDGE_SERIAL_NUMBER) + self.hass = hass - def _set_services(self): - add_preload_service(self, SERV_ACCESSORY_INFO) - add_preload_service(self, SERV_BRIDGING_STATE) + def setup_message(self): + """Prevent print of pyhap setup message to terminal.""" + pass + + +class HomeDriver(AccessoryDriver): + """Adapter class for AccessoryDriver.""" + + def __init__(self, hass, **kwargs): + """Initialize a AccessoryDriver object.""" + super().__init__(**kwargs) + self.hass = hass + + def pair(self, client_uuid, client_public): + """Override super function to dismiss setup message if paired.""" + success = super().pair(client_uuid, client_public) + if success: + dismiss_setup_message(self.hass) + return success + + def unpair(self, client_uuid): + """Override super function to show setup message if unpaired.""" + super().unpair(client_uuid) + show_setup_message(self.hass, self.state.pincode) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 73dfbf69049..dec6353850e 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,37 +1,123 @@ """Constants used be the HomeKit component.""" -MANUFACTURER = 'HomeAssistant' +# #### Misc #### +DEBOUNCE_TIMEOUT = 0.5 +DOMAIN = 'homekit' +HOMEKIT_FILE = '.homekit.state' +HOMEKIT_NOTIFY_ID = 4663548 -# Services +# #### Config #### +CONF_AUTO_START = 'auto_start' +CONF_ENTITY_CONFIG = 'entity_config' +CONF_FEATURE = 'feature' +CONF_FEATURE_LIST = 'feature_list' +CONF_FILTER = 'filter' + +# #### Config Defaults #### +DEFAULT_AUTO_START = True +DEFAULT_PORT = 51827 + +# #### Features #### +FEATURE_ON_OFF = 'on_off' +FEATURE_PLAY_PAUSE = 'play_pause' +FEATURE_PLAY_STOP = 'play_stop' +FEATURE_TOGGLE_MUTE = 'toggle_mute' + +# #### HomeKit Component Services #### +SERVICE_HOMEKIT_START = 'start' + +# #### String Constants #### +BRIDGE_MODEL = 'Bridge' +BRIDGE_NAME = 'Home Assistant Bridge' +BRIDGE_SERIAL_NUMBER = 'homekit.bridge' +MANUFACTURER = 'Home Assistant' + +# #### Switch Types #### +TYPE_OUTLET = 'outlet' +TYPE_SWITCH = 'switch' + +# #### Services #### SERV_ACCESSORY_INFO = 'AccessoryInformation' -SERV_BRIDGING_STATE = 'BridgingState' +SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor' +SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor' +SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor' +SERV_CONTACT_SENSOR = 'ContactSensor' +SERV_FANV2 = 'Fanv2' +SERV_GARAGE_DOOR_OPENER = 'GarageDoorOpener' +SERV_HUMIDITY_SENSOR = 'HumiditySensor' +SERV_LEAK_SENSOR = 'LeakSensor' +SERV_LIGHT_SENSOR = 'LightSensor' +SERV_LIGHTBULB = 'Lightbulb' +SERV_LOCK = 'LockMechanism' +SERV_MOTION_SENSOR = 'MotionSensor' +SERV_OCCUPANCY_SENSOR = 'OccupancySensor' +SERV_OUTLET = 'Outlet' SERV_SECURITY_SYSTEM = 'SecuritySystem' +SERV_SMOKE_SENSOR = 'SmokeSensor' SERV_SWITCH = 'Switch' SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' SERV_THERMOSTAT = 'Thermostat' SERV_WINDOW_COVERING = 'WindowCovering' -# Characteristics -CHAR_ACC_IDENTIFIER = 'AccessoryIdentifier' -CHAR_CATEGORY = 'Category' +# #### Characteristics #### +CHAR_ACTIVE = 'Active' +CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity' +CHAR_AIR_QUALITY = 'AirQuality' +CHAR_BRIGHTNESS = 'Brightness' +CHAR_CARBON_DIOXIDE_DETECTED = 'CarbonDioxideDetected' +CHAR_CARBON_DIOXIDE_LEVEL = 'CarbonDioxideLevel' +CHAR_CARBON_DIOXIDE_PEAK_LEVEL = 'CarbonDioxidePeakLevel' +CHAR_CARBON_MONOXIDE_DETECTED = 'CarbonMonoxideDetected' +CHAR_COLOR_TEMPERATURE = 'ColorTemperature' +CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState' CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' +CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = 'CurrentAmbientLightLevel' +CHAR_CURRENT_DOOR_STATE = 'CurrentDoorState' CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState' CHAR_CURRENT_POSITION = 'CurrentPosition' +CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity' CHAR_CURRENT_SECURITY_STATE = 'SecuritySystemCurrentState' CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' +CHAR_FIRMWARE_REVISION = 'FirmwareRevision' CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature' +CHAR_HUE = 'Hue' +CHAR_LEAK_DETECTED = 'LeakDetected' +CHAR_LOCK_CURRENT_STATE = 'LockCurrentState' +CHAR_LOCK_TARGET_STATE = 'LockTargetState' CHAR_LINK_QUALITY = 'LinkQuality' CHAR_MANUFACTURER = 'Manufacturer' CHAR_MODEL = 'Model' +CHAR_MOTION_DETECTED = 'MotionDetected' CHAR_NAME = 'Name' +CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected' +CHAR_OUTLET_IN_USE = 'OutletInUse' CHAR_ON = 'On' CHAR_POSITION_STATE = 'PositionState' -CHAR_REACHABLE = 'Reachable' +CHAR_ROTATION_DIRECTION = 'RotationDirection' +CHAR_SATURATION = 'Saturation' CHAR_SERIAL_NUMBER = 'SerialNumber' +CHAR_SMOKE_DETECTED = 'SmokeDetected' +CHAR_SWING_MODE = 'SwingMode' +CHAR_TARGET_DOOR_STATE = 'TargetDoorState' CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState' CHAR_TARGET_POSITION = 'TargetPosition' CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState' CHAR_TARGET_TEMPERATURE = 'TargetTemperature' CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits' -# Properties +# #### Properties #### +PROP_MAX_VALUE = 'maxValue' +PROP_MIN_VALUE = 'minValue' PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} + +# #### Device Classes #### +DEVICE_CLASS_CO2 = 'co2' +DEVICE_CLASS_DOOR = 'door' +DEVICE_CLASS_GARAGE_DOOR = 'garage_door' +DEVICE_CLASS_GAS = 'gas' +DEVICE_CLASS_MOISTURE = 'moisture' +DEVICE_CLASS_MOTION = 'motion' +DEVICE_CLASS_OCCUPANCY = 'occupancy' +DEVICE_CLASS_OPENING = 'opening' +DEVICE_CLASS_PM25 = 'pm25' +DEVICE_CLASS_SMOKE = 'smoke' +DEVICE_CLASS_WINDOW = 'window' diff --git a/homeassistant/components/homekit/covers.py b/homeassistant/components/homekit/covers.py deleted file mode 100644 index 47713f6c630..00000000000 --- a/homeassistant/components/homekit/covers.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Class to hold all cover accessories.""" -import logging - -from homeassistant.components.cover import ATTR_CURRENT_POSITION -from homeassistant.helpers.event import async_track_state_change - -from . import TYPES -from .accessories import HomeAccessory, add_preload_service -from .const import ( - SERV_WINDOW_COVERING, CHAR_CURRENT_POSITION, - CHAR_TARGET_POSITION, CHAR_POSITION_STATE) - - -_LOGGER = logging.getLogger(__name__) - - -@TYPES.register('Window') -class Window(HomeAccessory): - """Generate a Window accessory for a cover entity. - - The cover entity must support: set_cover_position. - """ - - def __init__(self, hass, entity_id, display_name): - """Initialize a Window accessory object.""" - super().__init__(display_name, entity_id, 'WINDOW') - - self._hass = hass - self._entity_id = entity_id - - self.current_position = None - self.homekit_target = None - - self.serv_cover = add_preload_service(self, SERV_WINDOW_COVERING) - self.char_current_position = self.serv_cover. \ - get_characteristic(CHAR_CURRENT_POSITION) - self.char_target_position = self.serv_cover. \ - get_characteristic(CHAR_TARGET_POSITION) - self.char_position_state = self.serv_cover. \ - get_characteristic(CHAR_POSITION_STATE) - self.char_current_position.value = 0 - self.char_target_position.value = 0 - self.char_position_state.value = 0 - - self.char_target_position.setter_callback = self.move_cover - - def run(self): - """Method called be object after driver is started.""" - state = self._hass.states.get(self._entity_id) - self.update_cover_position(new_state=state) - - async_track_state_change( - self._hass, self._entity_id, self.update_cover_position) - - def move_cover(self, value): - """Move cover to value if call came from HomeKit.""" - if value != self.current_position: - _LOGGER.debug("%s: Set position to %d", self._entity_id, value) - self.homekit_target = value - if value > self.current_position: - self.char_position_state.set_value(1) - elif value < self.current_position: - self.char_position_state.set_value(0) - self._hass.services.call( - 'cover', 'set_cover_position', - {'entity_id': self._entity_id, 'position': value}) - - def update_cover_position(self, entity_id=None, old_state=None, - new_state=None): - """Update cover position after state changed.""" - if new_state is None: - return - - current_position = new_state.attributes[ATTR_CURRENT_POSITION] - if current_position is None: - return - self.current_position = int(current_position) - self.char_current_position.set_value(self.current_position) - - if self.homekit_target is None or \ - abs(self.current_position - self.homekit_target) < 6: - self.char_target_position.set_value(self.current_position) - self.char_position_state.set_value(2) - self.homekit_target = None diff --git a/homeassistant/components/homekit/security_systems.py b/homeassistant/components/homekit/security_systems.py deleted file mode 100644 index 1b8f0a6820b..00000000000 --- a/homeassistant/components/homekit/security_systems.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Class to hold all alarm control panel accessories.""" -import logging - -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, - ATTR_ENTITY_ID, ATTR_CODE) -from homeassistant.helpers.event import async_track_state_change - -from . import TYPES -from .accessories import HomeAccessory, add_preload_service -from .const import ( - SERV_SECURITY_SYSTEM, CHAR_CURRENT_SECURITY_STATE, - CHAR_TARGET_SECURITY_STATE) - -_LOGGER = logging.getLogger(__name__) - -HASS_TO_HOMEKIT = {STATE_ALARM_DISARMED: 3, STATE_ALARM_ARMED_HOME: 0, - STATE_ALARM_ARMED_AWAY: 1, STATE_ALARM_ARMED_NIGHT: 2} -HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} -STATE_TO_SERVICE = {STATE_ALARM_DISARMED: 'alarm_disarm', - STATE_ALARM_ARMED_HOME: 'alarm_arm_home', - STATE_ALARM_ARMED_AWAY: 'alarm_arm_away', - STATE_ALARM_ARMED_NIGHT: 'alarm_arm_night'} - - -@TYPES.register('SecuritySystem') -class SecuritySystem(HomeAccessory): - """Generate an SecuritySystem accessory for an alarm control panel.""" - - def __init__(self, hass, entity_id, display_name, alarm_code=None): - """Initialize a SecuritySystem accessory object.""" - super().__init__(display_name, entity_id, 'ALARM_SYSTEM') - - self._hass = hass - self._entity_id = entity_id - self._alarm_code = alarm_code - - self.flag_target_state = False - - self.service_alarm = add_preload_service(self, SERV_SECURITY_SYSTEM) - self.char_current_state = self.service_alarm. \ - get_characteristic(CHAR_CURRENT_SECURITY_STATE) - self.char_current_state.value = 3 - self.char_target_state = self.service_alarm. \ - get_characteristic(CHAR_TARGET_SECURITY_STATE) - self.char_target_state.value = 3 - - self.char_target_state.setter_callback = self.set_security_state - - def run(self): - """Method called be object after driver is started.""" - state = self._hass.states.get(self._entity_id) - self.update_security_state(new_state=state) - - async_track_state_change(self._hass, self._entity_id, - self.update_security_state) - - def set_security_state(self, value): - """Move security state to value if call came from HomeKit.""" - _LOGGER.debug("%s: Set security state to %d", - self._entity_id, value) - self.flag_target_state = True - hass_value = HOMEKIT_TO_HASS[value] - service = STATE_TO_SERVICE[hass_value] - - params = {ATTR_ENTITY_ID: self._entity_id} - if self._alarm_code is not None: - params[ATTR_CODE] = self._alarm_code - self._hass.services.call('alarm_control_panel', service, params) - - def update_security_state(self, entity_id=None, - old_state=None, new_state=None): - """Update security state after state changed.""" - if new_state is None: - return - - hass_state = new_state.state - if hass_state not in HASS_TO_HOMEKIT: - return - current_security_state = HASS_TO_HOMEKIT[hass_state] - self.char_current_state.set_value(current_security_state) - _LOGGER.debug("%s: Updated current state to %s (%d)", - self._entity_id, hass_state, - current_security_state) - - if not self.flag_target_state: - self.char_target_state.set_value(current_security_state, - should_callback=False) - elif self.char_target_state.get_value() \ - == self.char_current_state.get_value(): - self.flag_target_state = False diff --git a/homeassistant/components/homekit/sensors.py b/homeassistant/components/homekit/sensors.py deleted file mode 100644 index 40f97ae3ef7..00000000000 --- a/homeassistant/components/homekit/sensors.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Class to hold all sensor accessories.""" -import logging - -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, TEMP_FAHRENHEIT, TEMP_CELSIUS) -from homeassistant.helpers.event import async_track_state_change - -from . import TYPES -from .accessories import ( - HomeAccessory, add_preload_service, override_properties) -from .const import ( - SERV_TEMPERATURE_SENSOR, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS) - - -_LOGGER = logging.getLogger(__name__) - - -def calc_temperature(state, unit=TEMP_CELSIUS): - """Calculate temperature from state and unit. - - Always return temperature as Celsius value. - Conversion is handled on the device. - """ - try: - value = float(state) - except ValueError: - return None - - return round((value - 32) / 1.8, 2) if unit == TEMP_FAHRENHEIT else value - - -@TYPES.register('TemperatureSensor') -class TemperatureSensor(HomeAccessory): - """Generate a TemperatureSensor accessory for a temperature sensor. - - Sensor entity must return temperature in °C, °F. - """ - - def __init__(self, hass, entity_id, display_name): - """Initialize a TemperatureSensor accessory object.""" - super().__init__(display_name, entity_id, 'SENSOR') - - self._hass = hass - self._entity_id = entity_id - - self.serv_temp = add_preload_service(self, SERV_TEMPERATURE_SENSOR) - self.char_temp = self.serv_temp. \ - get_characteristic(CHAR_CURRENT_TEMPERATURE) - override_properties(self.char_temp, PROP_CELSIUS) - self.char_temp.value = 0 - self.unit = None - - def run(self): - """Method called be object after driver is started.""" - state = self._hass.states.get(self._entity_id) - self.update_temperature(new_state=state) - - async_track_state_change( - self._hass, self._entity_id, self.update_temperature) - - def update_temperature(self, entity_id=None, old_state=None, - new_state=None): - """Update temperature after state changed.""" - if new_state is None: - return - - unit = new_state.attributes[ATTR_UNIT_OF_MEASUREMENT] - temperature = calc_temperature(new_state.state, unit) - if temperature is not None: - self.char_temp.set_value(temperature) - _LOGGER.debug("%s: Current temperature set to %d°C", - self._entity_id, temperature) diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml new file mode 100644 index 00000000000..e30e71301b3 --- /dev/null +++ b/homeassistant/components/homekit/services.yaml @@ -0,0 +1,4 @@ +# Describes the format for available HomeKit services + +start: + description: Starts the HomeKit component driver. diff --git a/homeassistant/components/homekit/switches.py b/homeassistant/components/homekit/switches.py deleted file mode 100644 index 876b3406d28..00000000000 --- a/homeassistant/components/homekit/switches.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Class to hold all switch accessories.""" -import logging - -from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import split_entity_id -from homeassistant.helpers.event import async_track_state_change - -from . import TYPES -from .accessories import HomeAccessory, add_preload_service -from .const import SERV_SWITCH, CHAR_ON - -_LOGGER = logging.getLogger(__name__) - - -@TYPES.register('Switch') -class Switch(HomeAccessory): - """Generate a Switch accessory.""" - - def __init__(self, hass, entity_id, display_name): - """Initialize a Switch accessory object to represent a remote.""" - super().__init__(display_name, entity_id, 'SWITCH') - - self._hass = hass - self._entity_id = entity_id - self._domain = split_entity_id(entity_id)[0] - - self.flag_target_state = False - - self.service_switch = add_preload_service(self, SERV_SWITCH) - self.char_on = self.service_switch.get_characteristic(CHAR_ON) - self.char_on.value = False - self.char_on.setter_callback = self.set_state - - def run(self): - """Method called be object after driver is started.""" - state = self._hass.states.get(self._entity_id) - self.update_state(new_state=state) - - async_track_state_change(self._hass, self._entity_id, - self.update_state) - - def set_state(self, value): - """Move switch state to value if call came from HomeKit.""" - _LOGGER.debug("%s: Set switch state to %s", - self._entity_id, value) - self.flag_target_state = True - service = 'turn_on' if value else 'turn_off' - self._hass.services.call(self._domain, service, - {ATTR_ENTITY_ID: self._entity_id}) - - def update_state(self, entity_id=None, old_state=None, new_state=None): - """Update switch state after state changed.""" - if new_state is None: - return - - current_state = (new_state.state == 'on') - if not self.flag_target_state: - _LOGGER.debug("%s: Set current state to %s", - self._entity_id, current_state) - self.char_on.set_value(current_state, should_callback=False) - else: - self.flag_target_state = False diff --git a/homeassistant/components/homekit/thermostats.py b/homeassistant/components/homekit/thermostats.py deleted file mode 100644 index 766a7e3585d..00000000000 --- a/homeassistant/components/homekit/thermostats.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Class to hold all thermostat accessories.""" -import logging - -from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, ATTR_TEMPERATURE, - ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, - STATE_HEAT, STATE_COOL, STATE_AUTO) -from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, - TEMP_CELSIUS, TEMP_FAHRENHEIT) -from homeassistant.helpers.event import async_track_state_change - -from . import TYPES -from .accessories import HomeAccessory, add_preload_service -from .const import ( - SERV_THERMOSTAT, CHAR_CURRENT_HEATING_COOLING, - CHAR_TARGET_HEATING_COOLING, CHAR_CURRENT_TEMPERATURE, - CHAR_TARGET_TEMPERATURE, CHAR_TEMP_DISPLAY_UNITS, - CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE) - -_LOGGER = logging.getLogger(__name__) - -STATE_OFF = 'off' -UNIT_HASS_TO_HOMEKIT = {TEMP_CELSIUS: 0, TEMP_FAHRENHEIT: 1} -UNIT_HOMEKIT_TO_HASS = {c: s for s, c in UNIT_HASS_TO_HOMEKIT.items()} -HC_HASS_TO_HOMEKIT = {STATE_OFF: 0, STATE_HEAT: 1, - STATE_COOL: 2, STATE_AUTO: 3} -HC_HOMEKIT_TO_HASS = {c: s for s, c in HC_HASS_TO_HOMEKIT.items()} - - -@TYPES.register('Thermostat') -class Thermostat(HomeAccessory): - """Generate a Thermostat accessory for a climate.""" - - def __init__(self, hass, entity_id, display_name, support_auto=False): - """Initialize a Thermostat accessory object.""" - super().__init__(display_name, entity_id, 'THERMOSTAT') - - self._hass = hass - self._entity_id = entity_id - self._call_timer = None - - self.heat_cool_flag_target_state = False - self.temperature_flag_target_state = False - self.coolingthresh_flag_target_state = False - self.heatingthresh_flag_target_state = False - - extra_chars = None - # Add additional characteristics if auto mode is supported - if support_auto: - extra_chars = [CHAR_COOLING_THRESHOLD_TEMPERATURE, - CHAR_HEATING_THRESHOLD_TEMPERATURE] - - # Preload the thermostat service - self.service_thermostat = add_preload_service(self, SERV_THERMOSTAT, - extra_chars) - - # Current and target mode characteristics - self.char_current_heat_cool = self.service_thermostat. \ - get_characteristic(CHAR_CURRENT_HEATING_COOLING) - self.char_current_heat_cool.value = 0 - self.char_target_heat_cool = self.service_thermostat. \ - get_characteristic(CHAR_TARGET_HEATING_COOLING) - self.char_target_heat_cool.value = 0 - self.char_target_heat_cool.setter_callback = self.set_heat_cool - - # Current and target temperature characteristics - self.char_current_temp = self.service_thermostat. \ - get_characteristic(CHAR_CURRENT_TEMPERATURE) - self.char_current_temp.value = 21.0 - self.char_target_temp = self.service_thermostat. \ - get_characteristic(CHAR_TARGET_TEMPERATURE) - self.char_target_temp.value = 21.0 - self.char_target_temp.setter_callback = self.set_target_temperature - - # Display units characteristic - self.char_display_units = self.service_thermostat. \ - get_characteristic(CHAR_TEMP_DISPLAY_UNITS) - self.char_display_units.value = 0 - - # If the device supports it: high and low temperature characteristics - if support_auto: - self.char_cooling_thresh_temp = self.service_thermostat. \ - get_characteristic(CHAR_COOLING_THRESHOLD_TEMPERATURE) - self.char_cooling_thresh_temp.value = 23.0 - self.char_cooling_thresh_temp.setter_callback = \ - self.set_cooling_threshold - - self.char_heating_thresh_temp = self.service_thermostat. \ - get_characteristic(CHAR_HEATING_THRESHOLD_TEMPERATURE) - self.char_heating_thresh_temp.value = 19.0 - self.char_heating_thresh_temp.setter_callback = \ - self.set_heating_threshold - else: - self.char_cooling_thresh_temp = None - self.char_heating_thresh_temp = None - - def run(self): - """Method called be object after driver is started.""" - state = self._hass.states.get(self._entity_id) - self.update_thermostat(new_state=state) - - async_track_state_change(self._hass, self._entity_id, - self.update_thermostat) - - def set_heat_cool(self, value): - """Move operation mode to value if call came from HomeKit.""" - if value in HC_HOMEKIT_TO_HASS: - _LOGGER.debug("%s: Set heat-cool to %d", self._entity_id, value) - self.heat_cool_flag_target_state = True - hass_value = HC_HOMEKIT_TO_HASS[value] - self._hass.services.call('climate', 'set_operation_mode', - {ATTR_ENTITY_ID: self._entity_id, - ATTR_OPERATION_MODE: hass_value}) - - def set_cooling_threshold(self, value): - """Set cooling threshold temp to value if call came from HomeKit.""" - _LOGGER.debug("%s: Set cooling threshold temperature to %.2f", - self._entity_id, value) - self.coolingthresh_flag_target_state = True - low = self.char_heating_thresh_temp.get_value() - self._hass.services.call( - 'climate', 'set_temperature', - {ATTR_ENTITY_ID: self._entity_id, - ATTR_TARGET_TEMP_HIGH: value, - ATTR_TARGET_TEMP_LOW: low}) - - def set_heating_threshold(self, value): - """Set heating threshold temp to value if call came from HomeKit.""" - _LOGGER.debug("%s: Set heating threshold temperature to %.2f", - self._entity_id, value) - self.heatingthresh_flag_target_state = True - # Home assistant always wants to set low and high at the same time - high = self.char_cooling_thresh_temp.get_value() - self._hass.services.call( - 'climate', 'set_temperature', - {ATTR_ENTITY_ID: self._entity_id, - ATTR_TARGET_TEMP_LOW: value, - ATTR_TARGET_TEMP_HIGH: high}) - - def set_target_temperature(self, value): - """Set target temperature to value if call came from HomeKit.""" - _LOGGER.debug("%s: Set target temperature to %.2f", - self._entity_id, value) - self.temperature_flag_target_state = True - self._hass.services.call( - 'climate', 'set_temperature', - {ATTR_ENTITY_ID: self._entity_id, - ATTR_TEMPERATURE: value}) - - def update_thermostat(self, entity_id=None, - old_state=None, new_state=None): - """Update security state after state changed.""" - if new_state is None: - return - - # Update current temperature - current_temp = new_state.attributes.get(ATTR_CURRENT_TEMPERATURE) - if current_temp is not None: - self.char_current_temp.set_value(current_temp) - - # Update target temperature - target_temp = new_state.attributes.get(ATTR_TEMPERATURE) - if target_temp is not None: - if not self.temperature_flag_target_state: - self.char_target_temp.set_value(target_temp, - should_callback=False) - else: - self.temperature_flag_target_state = False - - # Update cooling threshold temperature if characteristic exists - if self.char_cooling_thresh_temp is not None: - cooling_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) - if cooling_thresh is not None: - if not self.coolingthresh_flag_target_state: - self.char_cooling_thresh_temp.set_value( - cooling_thresh, should_callback=False) - else: - self.coolingthresh_flag_target_state = False - - # Update heating threshold temperature if characteristic exists - if self.char_heating_thresh_temp is not None: - heating_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_LOW) - if heating_thresh is not None: - if not self.heatingthresh_flag_target_state: - self.char_heating_thresh_temp.set_value( - heating_thresh, should_callback=False) - else: - self.heatingthresh_flag_target_state = False - - # Update display units - display_units = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - if display_units is not None \ - and display_units in UNIT_HASS_TO_HOMEKIT: - self.char_display_units.set_value( - UNIT_HASS_TO_HOMEKIT[display_units]) - - # Update target operation mode - operation_mode = new_state.attributes.get(ATTR_OPERATION_MODE) - if operation_mode is not None \ - and operation_mode in HC_HASS_TO_HOMEKIT: - if not self.heat_cool_flag_target_state: - self.char_target_heat_cool.set_value( - HC_HASS_TO_HOMEKIT[operation_mode], should_callback=False) - else: - self.heat_cool_flag_target_state = False - - # Set current operation mode based on temperatures and target mode - if operation_mode == STATE_HEAT: - if current_temp < target_temp: - current_operation_mode = STATE_HEAT - else: - current_operation_mode = STATE_OFF - elif operation_mode == STATE_COOL: - if current_temp > target_temp: - current_operation_mode = STATE_COOL - else: - current_operation_mode = STATE_OFF - elif operation_mode == STATE_AUTO: - # Check if auto is supported - if self.char_cooling_thresh_temp is not None: - lower_temp = self.char_heating_thresh_temp.get_value() - upper_temp = self.char_cooling_thresh_temp.get_value() - if current_temp < lower_temp: - current_operation_mode = STATE_HEAT - elif current_temp > upper_temp: - current_operation_mode = STATE_COOL - else: - current_operation_mode = STATE_OFF - else: - # Check if heating or cooling are supported - heat = STATE_HEAT in new_state.attributes[ATTR_OPERATION_LIST] - cool = STATE_COOL in new_state.attributes[ATTR_OPERATION_LIST] - if current_temp < target_temp and heat: - current_operation_mode = STATE_HEAT - elif current_temp > target_temp and cool: - current_operation_mode = STATE_COOL - else: - current_operation_mode = STATE_OFF - else: - current_operation_mode = STATE_OFF - - self.char_current_heat_cool.set_value( - HC_HASS_TO_HOMEKIT[current_operation_mode]) diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py new file mode 100644 index 00000000000..cf0620a4e30 --- /dev/null +++ b/homeassistant/components/homekit/type_covers.py @@ -0,0 +1,160 @@ +"""Class to hold all cover accessories.""" +import logging + +from pyhap.const import CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW_COVERING + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, + STATE_CLOSED, STATE_OPEN) + +from . import TYPES +from .accessories import debounce, HomeAccessory +from .const import ( + CHAR_CURRENT_DOOR_STATE, CHAR_CURRENT_POSITION, CHAR_POSITION_STATE, + CHAR_TARGET_DOOR_STATE, CHAR_TARGET_POSITION, + SERV_GARAGE_DOOR_OPENER, SERV_WINDOW_COVERING) + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register('GarageDoorOpener') +class GarageDoorOpener(HomeAccessory): + """Generate a Garage Door Opener accessory for a cover entity. + + The cover entity must be in the 'garage' device class + and support no more than open, close, and stop. + """ + + def __init__(self, *args): + """Initialize a GarageDoorOpener accessory object.""" + super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER) + self.flag_target_state = False + + serv_garage_door = self.add_preload_service(SERV_GARAGE_DOOR_OPENER) + self.char_current_state = serv_garage_door.configure_char( + CHAR_CURRENT_DOOR_STATE, value=0) + self.char_target_state = serv_garage_door.configure_char( + CHAR_TARGET_DOOR_STATE, value=0, setter_callback=self.set_state) + + def set_state(self, value): + """Change garage state if call came from HomeKit.""" + _LOGGER.debug('%s: Set state to %d', self.entity_id, value) + self.flag_target_state = True + + params = {ATTR_ENTITY_ID: self.entity_id} + if value == 0: + self.char_current_state.set_value(3) + self.hass.services.call(DOMAIN, SERVICE_OPEN_COVER, params) + elif value == 1: + self.char_current_state.set_value(2) + self.hass.services.call(DOMAIN, SERVICE_CLOSE_COVER, params) + + def update_state(self, new_state): + """Update cover state after state changed.""" + hass_state = new_state.state + if hass_state in (STATE_OPEN, STATE_CLOSED): + current_state = 0 if hass_state == STATE_OPEN else 1 + self.char_current_state.set_value(current_state) + if not self.flag_target_state: + self.char_target_state.set_value(current_state) + self.flag_target_state = False + + +@TYPES.register('WindowCovering') +class WindowCovering(HomeAccessory): + """Generate a Window accessory for a cover entity. + + The cover entity must support: set_cover_position. + """ + + def __init__(self, *args): + """Initialize a WindowCovering accessory object.""" + super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + self.homekit_target = None + + serv_cover = self.add_preload_service(SERV_WINDOW_COVERING) + self.char_current_position = serv_cover.configure_char( + CHAR_CURRENT_POSITION, value=0) + self.char_target_position = serv_cover.configure_char( + CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover) + + @debounce + def move_cover(self, value): + """Move cover to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set position to %d', self.entity_id, value) + self.homekit_target = value + + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_POSITION: value} + self.hass.services.call(DOMAIN, SERVICE_SET_COVER_POSITION, params) + + def update_state(self, new_state): + """Update cover position after state changed.""" + current_position = new_state.attributes.get(ATTR_CURRENT_POSITION) + if isinstance(current_position, int): + self.char_current_position.set_value(current_position) + if self.homekit_target is None or \ + abs(current_position - self.homekit_target) < 6: + self.char_target_position.set_value(current_position) + self.homekit_target = None + + +@TYPES.register('WindowCoveringBasic') +class WindowCoveringBasic(HomeAccessory): + """Generate a Window accessory for a cover entity. + + The cover entity must support: open_cover, close_cover, + stop_cover (optional). + """ + + def __init__(self, *args): + """Initialize a WindowCovering accessory object.""" + super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + features = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SUPPORTED_FEATURES) + self.supports_stop = features & SUPPORT_STOP + + serv_cover = self.add_preload_service(SERV_WINDOW_COVERING) + self.char_current_position = serv_cover.configure_char( + CHAR_CURRENT_POSITION, value=0) + self.char_target_position = serv_cover.configure_char( + CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover) + self.char_position_state = serv_cover.configure_char( + CHAR_POSITION_STATE, value=2) + + @debounce + def move_cover(self, value): + """Move cover to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set position to %d', self.entity_id, value) + + if self.supports_stop: + if value > 70: + service, position = (SERVICE_OPEN_COVER, 100) + elif value < 30: + service, position = (SERVICE_CLOSE_COVER, 0) + else: + service, position = (SERVICE_STOP_COVER, 50) + else: + if value >= 50: + service, position = (SERVICE_OPEN_COVER, 100) + else: + service, position = (SERVICE_CLOSE_COVER, 0) + + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) + + # Snap the current/target position to the expected final position. + self.char_current_position.set_value(position) + self.char_target_position.set_value(position) + self.char_position_state.set_value(2) + + def update_state(self, new_state): + """Update cover position after state changed.""" + position_mapping = {STATE_OPEN: 100, STATE_CLOSED: 0} + hk_position = position_mapping.get(new_state.state) + if hk_position is not None: + self.char_current_position.set_value(hk_position) + self.char_target_position.set_value(hk_position) + self.char_position_state.set_value(2) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py new file mode 100644 index 00000000000..bf0d4da6a59 --- /dev/null +++ b/homeassistant/components/homekit/type_fans.py @@ -0,0 +1,115 @@ +"""Class to hold all light accessories.""" +import logging + +from pyhap.const import CATEGORY_FAN + +from homeassistant.components.fan import ( + ATTR_DIRECTION, ATTR_OSCILLATING, DIRECTION_FORWARD, DIRECTION_REVERSE, + DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SUPPORT_DIRECTION, + SUPPORT_OSCILLATE) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, + SERVICE_TURN_ON, STATE_OFF, STATE_ON) + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + CHAR_ACTIVE, CHAR_ROTATION_DIRECTION, CHAR_SWING_MODE, SERV_FANV2) + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register('Fan') +class Fan(HomeAccessory): + """Generate a Fan accessory for a fan entity. + + Currently supports: state, speed, oscillate, direction. + """ + + def __init__(self, *args): + """Initialize a new Light accessory object.""" + super().__init__(*args, category=CATEGORY_FAN) + self._flag = {CHAR_ACTIVE: False, + CHAR_ROTATION_DIRECTION: False, + CHAR_SWING_MODE: False} + self._state = 0 + + self.chars = [] + features = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SUPPORTED_FEATURES) + if features & SUPPORT_DIRECTION: + self.chars.append(CHAR_ROTATION_DIRECTION) + if features & SUPPORT_OSCILLATE: + self.chars.append(CHAR_SWING_MODE) + + serv_fan = self.add_preload_service(SERV_FANV2, self.chars) + self.char_active = serv_fan.configure_char( + CHAR_ACTIVE, value=0, setter_callback=self.set_state) + + if CHAR_ROTATION_DIRECTION in self.chars: + self.char_direction = serv_fan.configure_char( + CHAR_ROTATION_DIRECTION, value=0, + setter_callback=self.set_direction) + + if CHAR_SWING_MODE in self.chars: + self.char_swing = serv_fan.configure_char( + CHAR_SWING_MODE, value=0, setter_callback=self.set_oscillating) + + def set_state(self, value): + """Set state if call came from HomeKit.""" + if self._state == value: + return + + _LOGGER.debug('%s: Set state to %d', self.entity_id, value) + self._flag[CHAR_ACTIVE] = True + service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) + + def set_direction(self, value): + """Set state if call came from HomeKit.""" + _LOGGER.debug('%s: Set direction to %d', self.entity_id, value) + self._flag[CHAR_ROTATION_DIRECTION] = True + direction = DIRECTION_REVERSE if value == 1 else DIRECTION_FORWARD + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_DIRECTION: direction} + self.hass.services.call(DOMAIN, SERVICE_SET_DIRECTION, params) + + def set_oscillating(self, value): + """Set state if call came from HomeKit.""" + _LOGGER.debug('%s: Set oscillating to %d', self.entity_id, value) + self._flag[CHAR_SWING_MODE] = True + oscillating = True if value == 1 else False + params = {ATTR_ENTITY_ID: self.entity_id, + ATTR_OSCILLATING: oscillating} + self.hass.services.call(DOMAIN, SERVICE_OSCILLATE, params) + + def update_state(self, new_state): + """Update fan after state change.""" + # Handle State + state = new_state.state + if state in (STATE_ON, STATE_OFF): + self._state = 1 if state == STATE_ON else 0 + if not self._flag[CHAR_ACTIVE] and \ + self.char_active.value != self._state: + self.char_active.set_value(self._state) + self._flag[CHAR_ACTIVE] = False + + # Handle Direction + if CHAR_ROTATION_DIRECTION in self.chars: + direction = new_state.attributes.get(ATTR_DIRECTION) + if not self._flag[CHAR_ROTATION_DIRECTION] and \ + direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): + hk_direction = 1 if direction == DIRECTION_REVERSE else 0 + if self.char_direction.value != hk_direction: + self.char_direction.set_value(hk_direction) + self._flag[CHAR_ROTATION_DIRECTION] = False + + # Handle Oscillating + if CHAR_SWING_MODE in self.chars: + oscillating = new_state.attributes.get(ATTR_OSCILLATING) + if not self._flag[CHAR_SWING_MODE] and \ + oscillating in (True, False): + hk_oscillating = 1 if oscillating else 0 + if self.char_swing.value != hk_oscillating: + self.char_swing.set_value(hk_oscillating) + self._flag[CHAR_SWING_MODE] = False diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py new file mode 100644 index 00000000000..da012799602 --- /dev/null +++ b/homeassistant/components/homekit/type_lights.py @@ -0,0 +1,170 @@ +"""Class to hold all light accessories.""" +import logging + +from pyhap.const import CATEGORY_LIGHTBULB + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, DOMAIN, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_ON, + SERVICE_TURN_OFF, STATE_OFF, STATE_ON) + +from . import TYPES +from .accessories import debounce, HomeAccessory +from .const import ( + CHAR_BRIGHTNESS, CHAR_COLOR_TEMPERATURE, CHAR_HUE, CHAR_ON, + CHAR_SATURATION, SERV_LIGHTBULB, PROP_MAX_VALUE, PROP_MIN_VALUE) + +_LOGGER = logging.getLogger(__name__) + +RGB_COLOR = 'rgb_color' + + +@TYPES.register('Light') +class Light(HomeAccessory): + """Generate a Light accessory for a light entity. + + Currently supports: state, brightness, color temperature, rgb_color. + """ + + def __init__(self, *args): + """Initialize a new Light accessory object.""" + super().__init__(*args, category=CATEGORY_LIGHTBULB) + self._flag = {CHAR_ON: False, CHAR_BRIGHTNESS: False, + CHAR_HUE: False, CHAR_SATURATION: False, + CHAR_COLOR_TEMPERATURE: False, RGB_COLOR: False} + self._state = 0 + + self.chars = [] + self._features = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SUPPORTED_FEATURES) + if self._features & SUPPORT_BRIGHTNESS: + self.chars.append(CHAR_BRIGHTNESS) + if self._features & SUPPORT_COLOR_TEMP: + self.chars.append(CHAR_COLOR_TEMPERATURE) + if self._features & SUPPORT_COLOR: + self.chars.append(CHAR_HUE) + self.chars.append(CHAR_SATURATION) + self._hue = None + self._saturation = None + + serv_light = self.add_preload_service(SERV_LIGHTBULB, self.chars) + self.char_on = serv_light.configure_char( + CHAR_ON, value=self._state, setter_callback=self.set_state) + + if CHAR_BRIGHTNESS in self.chars: + self.char_brightness = serv_light.configure_char( + CHAR_BRIGHTNESS, value=0, setter_callback=self.set_brightness) + if CHAR_COLOR_TEMPERATURE in self.chars: + min_mireds = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_MIN_MIREDS, 153) + max_mireds = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_MAX_MIREDS, 500) + self.char_color_temperature = serv_light.configure_char( + CHAR_COLOR_TEMPERATURE, value=min_mireds, + properties={PROP_MIN_VALUE: min_mireds, + PROP_MAX_VALUE: max_mireds}, + setter_callback=self.set_color_temperature) + if CHAR_HUE in self.chars: + self.char_hue = serv_light.configure_char( + CHAR_HUE, value=0, setter_callback=self.set_hue) + if CHAR_SATURATION in self.chars: + self.char_saturation = serv_light.configure_char( + CHAR_SATURATION, value=75, setter_callback=self.set_saturation) + + def set_state(self, value): + """Set state if call came from HomeKit.""" + if self._state == value: + return + + _LOGGER.debug('%s: Set state to %d', self.entity_id, value) + self._flag[CHAR_ON] = True + params = {ATTR_ENTITY_ID: self.entity_id} + service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF + self.hass.services.call(DOMAIN, service, params) + + @debounce + def set_brightness(self, value): + """Set brightness if call came from HomeKit.""" + _LOGGER.debug('%s: Set brightness to %d', self.entity_id, value) + self._flag[CHAR_BRIGHTNESS] = True + if value == 0: + self.set_state(0) # Turn off light + return + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_BRIGHTNESS_PCT: value} + self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) + + def set_color_temperature(self, value): + """Set color temperature if call came from HomeKit.""" + _LOGGER.debug('%s: Set color temp to %s', self.entity_id, value) + self._flag[CHAR_COLOR_TEMPERATURE] = True + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_COLOR_TEMP: value} + self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) + + def set_saturation(self, value): + """Set saturation if call came from HomeKit.""" + _LOGGER.debug('%s: Set saturation to %d', self.entity_id, value) + self._flag[CHAR_SATURATION] = True + self._saturation = value + self.set_color() + + def set_hue(self, value): + """Set hue if call came from HomeKit.""" + _LOGGER.debug('%s: Set hue to %d', self.entity_id, value) + self._flag[CHAR_HUE] = True + self._hue = value + self.set_color() + + def set_color(self): + """Set color if call came from HomeKit.""" + if self._features & SUPPORT_COLOR and self._flag[CHAR_HUE] and \ + self._flag[CHAR_SATURATION]: + color = (self._hue, self._saturation) + _LOGGER.debug('%s: Set hs_color to %s', self.entity_id, color) + self._flag.update({ + CHAR_HUE: False, CHAR_SATURATION: False, RGB_COLOR: True}) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HS_COLOR: color} + self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) + + def update_state(self, new_state): + """Update light after state change.""" + # Handle State + state = new_state.state + if state in (STATE_ON, STATE_OFF): + self._state = 1 if state == STATE_ON else 0 + if not self._flag[CHAR_ON] and self.char_on.value != self._state: + self.char_on.set_value(self._state) + self._flag[CHAR_ON] = False + + # Handle Brightness + if CHAR_BRIGHTNESS in self.chars: + brightness = new_state.attributes.get(ATTR_BRIGHTNESS) + if not self._flag[CHAR_BRIGHTNESS] and isinstance(brightness, int): + brightness = round(brightness / 255 * 100, 0) + if self.char_brightness.value != brightness: + self.char_brightness.set_value(brightness) + self._flag[CHAR_BRIGHTNESS] = False + + # Handle color temperature + if CHAR_COLOR_TEMPERATURE in self.chars: + color_temperature = new_state.attributes.get(ATTR_COLOR_TEMP) + if not self._flag[CHAR_COLOR_TEMPERATURE] \ + and isinstance(color_temperature, int) and \ + self.char_color_temperature.value != color_temperature: + self.char_color_temperature.set_value(color_temperature) + self._flag[CHAR_COLOR_TEMPERATURE] = False + + # Handle Color + if CHAR_SATURATION in self.chars and CHAR_HUE in self.chars: + hue, saturation = new_state.attributes.get( + ATTR_HS_COLOR, (None, None)) + if not self._flag[RGB_COLOR] and ( + hue != self._hue or saturation != self._saturation) and \ + isinstance(hue, (int, float)) and \ + isinstance(saturation, (int, float)): + self.char_hue.set_value(hue) + self.char_saturation.set_value(saturation) + self._hue, self._saturation = (hue, saturation) + self._flag[RGB_COLOR] = False diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py new file mode 100644 index 00000000000..05ab6c6f822 --- /dev/null +++ b/homeassistant/components/homekit/type_locks.py @@ -0,0 +1,72 @@ +"""Class to hold all lock accessories.""" +import logging + +from pyhap.const import CATEGORY_DOOR_LOCK + +from homeassistant.components.lock import ( + ATTR_ENTITY_ID, DOMAIN, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN) +from homeassistant.const import ATTR_CODE + +from . import TYPES +from .accessories import HomeAccessory +from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK + +_LOGGER = logging.getLogger(__name__) + +HASS_TO_HOMEKIT = {STATE_UNLOCKED: 0, + STATE_LOCKED: 1, + # value 2 is Jammed which hass doesn't have a state for + STATE_UNKNOWN: 3} +HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} +STATE_TO_SERVICE = {STATE_LOCKED: 'lock', + STATE_UNLOCKED: 'unlock'} + + +@TYPES.register('Lock') +class Lock(HomeAccessory): + """Generate a Lock accessory for a lock entity. + + The lock entity must support: unlock and lock. + """ + + def __init__(self, *args): + """Initialize a Lock accessory object.""" + super().__init__(*args, category=CATEGORY_DOOR_LOCK) + self._code = self.config.get(ATTR_CODE) + self.flag_target_state = False + + serv_lock_mechanism = self.add_preload_service(SERV_LOCK) + self.char_current_state = serv_lock_mechanism.configure_char( + CHAR_LOCK_CURRENT_STATE, + value=HASS_TO_HOMEKIT[STATE_UNKNOWN]) + self.char_target_state = serv_lock_mechanism.configure_char( + CHAR_LOCK_TARGET_STATE, value=HASS_TO_HOMEKIT[STATE_LOCKED], + setter_callback=self.set_state) + + def set_state(self, value): + """Set lock state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set state to %d", self.entity_id, value) + self.flag_target_state = True + + hass_value = HOMEKIT_TO_HASS.get(value) + service = STATE_TO_SERVICE[hass_value] + + params = {ATTR_ENTITY_ID: self.entity_id} + if self._code: + params[ATTR_CODE] = self._code + self.hass.services.call(DOMAIN, service, params) + + def update_state(self, new_state): + """Update lock after state changed.""" + hass_state = new_state.state + if hass_state in HASS_TO_HOMEKIT: + current_lock_state = HASS_TO_HOMEKIT[hass_state] + self.char_current_state.set_value(current_lock_state) + _LOGGER.debug('%s: Updated current state to %s (%d)', + self.entity_id, hass_state, current_lock_state) + + # LockTargetState only supports locked and unlocked + if hass_state in (STATE_LOCKED, STATE_UNLOCKED): + if not self.flag_target_state: + self.char_target_state.set_value(current_lock_state) + self.flag_target_state = False diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py new file mode 100644 index 00000000000..ec41b9fd618 --- /dev/null +++ b/homeassistant/components/homekit/type_media_players.py @@ -0,0 +1,142 @@ +"""Class to hold all media player accessories.""" +import logging + +from pyhap.const import CATEGORY_SWITCH + +from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_MUTE, + STATE_OFF, STATE_PLAYING, STATE_UNKNOWN) +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_MUTED, DOMAIN) + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + CHAR_NAME, CHAR_ON, CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, SERV_SWITCH) + +_LOGGER = logging.getLogger(__name__) + +MODE_FRIENDLY_NAME = {FEATURE_ON_OFF: 'Power', + FEATURE_PLAY_PAUSE: 'Play/Pause', + FEATURE_PLAY_STOP: 'Play/Stop', + FEATURE_TOGGLE_MUTE: 'Mute'} + + +@TYPES.register('MediaPlayer') +class MediaPlayer(HomeAccessory): + """Generate a Media Player accessory.""" + + def __init__(self, *args): + """Initialize a Switch accessory object.""" + super().__init__(*args, category=CATEGORY_SWITCH) + self._flag = {FEATURE_ON_OFF: False, FEATURE_PLAY_PAUSE: False, + FEATURE_PLAY_STOP: False, FEATURE_TOGGLE_MUTE: False} + self.chars = {FEATURE_ON_OFF: None, FEATURE_PLAY_PAUSE: None, + FEATURE_PLAY_STOP: None, FEATURE_TOGGLE_MUTE: None} + feature_list = self.config[CONF_FEATURE_LIST] + + if FEATURE_ON_OFF in feature_list: + name = self.generate_service_name(FEATURE_ON_OFF) + serv_on_off = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_on_off.configure_char(CHAR_NAME, value=name) + self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char( + CHAR_ON, value=False, setter_callback=self.set_on_off) + + if FEATURE_PLAY_PAUSE in feature_list: + name = self.generate_service_name(FEATURE_PLAY_PAUSE) + serv_play_pause = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_play_pause.configure_char(CHAR_NAME, value=name) + self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char( + CHAR_ON, value=False, setter_callback=self.set_play_pause) + + if FEATURE_PLAY_STOP in feature_list: + name = self.generate_service_name(FEATURE_PLAY_STOP) + serv_play_stop = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_play_stop.configure_char(CHAR_NAME, value=name) + self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char( + CHAR_ON, value=False, setter_callback=self.set_play_stop) + + if FEATURE_TOGGLE_MUTE in feature_list: + name = self.generate_service_name(FEATURE_TOGGLE_MUTE) + serv_toggle_mute = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_toggle_mute.configure_char(CHAR_NAME, value=name) + self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char( + CHAR_ON, value=False, setter_callback=self.set_toggle_mute) + + def generate_service_name(self, mode): + """Generate name for individual service.""" + return '{} {}'.format(self.display_name, MODE_FRIENDLY_NAME[mode]) + + def set_on_off(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "on_off" to %s', + self.entity_id, value) + self._flag[FEATURE_ON_OFF] = True + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) + + def set_play_pause(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "play_pause" to %s', + self.entity_id, value) + self._flag[FEATURE_PLAY_PAUSE] = True + service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_PAUSE + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) + + def set_play_stop(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "play_stop" to %s', + self.entity_id, value) + self._flag[FEATURE_PLAY_STOP] = True + service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_STOP + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) + + def set_toggle_mute(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "toggle_mute" to %s', + self.entity_id, value) + self._flag[FEATURE_TOGGLE_MUTE] = True + params = {ATTR_ENTITY_ID: self.entity_id, + ATTR_MEDIA_VOLUME_MUTED: value} + self.hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE, params) + + def update_state(self, new_state): + """Update switch state after state changed.""" + current_state = new_state.state + + if self.chars[FEATURE_ON_OFF]: + hk_state = current_state not in (STATE_OFF, STATE_UNKNOWN, 'None') + if not self._flag[FEATURE_ON_OFF]: + _LOGGER.debug('%s: Set current state for "on_off" to %s', + self.entity_id, hk_state) + self.chars[FEATURE_ON_OFF].set_value(hk_state) + self._flag[FEATURE_ON_OFF] = False + + if self.chars[FEATURE_PLAY_PAUSE]: + hk_state = current_state == STATE_PLAYING + if not self._flag[FEATURE_PLAY_PAUSE]: + _LOGGER.debug('%s: Set current state for "play_pause" to %s', + self.entity_id, hk_state) + self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state) + self._flag[FEATURE_PLAY_PAUSE] = False + + if self.chars[FEATURE_PLAY_STOP]: + hk_state = current_state == STATE_PLAYING + if not self._flag[FEATURE_PLAY_STOP]: + _LOGGER.debug('%s: Set current state for "play_stop" to %s', + self.entity_id, hk_state) + self.chars[FEATURE_PLAY_STOP].set_value(hk_state) + self._flag[FEATURE_PLAY_STOP] = False + + if self.chars[FEATURE_TOGGLE_MUTE]: + current_state = new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) + if not self._flag[FEATURE_TOGGLE_MUTE]: + _LOGGER.debug('%s: Set current state for "toggle_mute" to %s', + self.entity_id, current_state) + self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) + self._flag[FEATURE_TOGGLE_MUTE] = False diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py new file mode 100644 index 00000000000..bbf8b3f17cb --- /dev/null +++ b/homeassistant/components/homekit/type_security_systems.py @@ -0,0 +1,74 @@ +"""Class to hold all alarm control panel accessories.""" +import logging + +from pyhap.const import CATEGORY_ALARM_SYSTEM + +from homeassistant.components.alarm_control_panel import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED, STATE_ALARM_DISARMED) + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + CHAR_CURRENT_SECURITY_STATE, CHAR_TARGET_SECURITY_STATE, + SERV_SECURITY_SYSTEM) + +_LOGGER = logging.getLogger(__name__) + +HASS_TO_HOMEKIT = {STATE_ALARM_ARMED_HOME: 0, + STATE_ALARM_ARMED_AWAY: 1, + STATE_ALARM_ARMED_NIGHT: 2, + STATE_ALARM_DISARMED: 3, + STATE_ALARM_TRIGGERED: 4} +HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} +STATE_TO_SERVICE = {STATE_ALARM_ARMED_HOME: 'alarm_arm_home', + STATE_ALARM_ARMED_AWAY: 'alarm_arm_away', + STATE_ALARM_ARMED_NIGHT: 'alarm_arm_night', + STATE_ALARM_DISARMED: 'alarm_disarm'} + + +@TYPES.register('SecuritySystem') +class SecuritySystem(HomeAccessory): + """Generate an SecuritySystem accessory for an alarm control panel.""" + + def __init__(self, *args): + """Initialize a SecuritySystem accessory object.""" + super().__init__(*args, category=CATEGORY_ALARM_SYSTEM) + self._alarm_code = self.config.get(ATTR_CODE) + self.flag_target_state = False + + serv_alarm = self.add_preload_service(SERV_SECURITY_SYSTEM) + self.char_current_state = serv_alarm.configure_char( + CHAR_CURRENT_SECURITY_STATE, value=3) + self.char_target_state = serv_alarm.configure_char( + CHAR_TARGET_SECURITY_STATE, value=3, + setter_callback=self.set_security_state) + + def set_security_state(self, value): + """Move security state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set security state to %d', + self.entity_id, value) + self.flag_target_state = True + hass_value = HOMEKIT_TO_HASS[value] + service = STATE_TO_SERVICE[hass_value] + + params = {ATTR_ENTITY_ID: self.entity_id} + if self._alarm_code: + params[ATTR_CODE] = self._alarm_code + self.hass.services.call(DOMAIN, service, params) + + def update_state(self, new_state): + """Update security state after state changed.""" + hass_state = new_state.state + if hass_state in HASS_TO_HOMEKIT: + current_security_state = HASS_TO_HOMEKIT[hass_state] + self.char_current_state.set_value(current_security_state) + _LOGGER.debug('%s: Updated current state to %s (%d)', + self.entity_id, hass_state, current_security_state) + + # SecuritySystemTargetState does not support triggered + if not self.flag_target_state and \ + hass_state != STATE_ALARM_TRIGGERED: + self.char_target_state.set_value(current_security_state) + self.flag_target_state = False diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py new file mode 100644 index 00000000000..373c1188f2d --- /dev/null +++ b/homeassistant/components/homekit/type_sensors.py @@ -0,0 +1,186 @@ +"""Class to hold all sensor accessories.""" +import logging + +from pyhap.const import CATEGORY_SENSOR + +from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_HOME, + TEMP_CELSIUS) + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + CHAR_AIR_PARTICULATE_DENSITY, CHAR_AIR_QUALITY, + CHAR_CARBON_DIOXIDE_DETECTED, CHAR_CARBON_DIOXIDE_LEVEL, + CHAR_CARBON_DIOXIDE_PEAK_LEVEL, CHAR_CARBON_MONOXIDE_DETECTED, + CHAR_CONTACT_SENSOR_STATE, CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, + CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, CHAR_LEAK_DETECTED, + CHAR_MOTION_DETECTED, CHAR_OCCUPANCY_DETECTED, CHAR_SMOKE_DETECTED, + DEVICE_CLASS_CO2, DEVICE_CLASS_DOOR, DEVICE_CLASS_GARAGE_DOOR, + DEVICE_CLASS_GAS, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, DEVICE_CLASS_OPENING, DEVICE_CLASS_SMOKE, + DEVICE_CLASS_WINDOW, PROP_CELSIUS, SERV_AIR_QUALITY_SENSOR, + SERV_CARBON_DIOXIDE_SENSOR, SERV_CARBON_MONOXIDE_SENSOR, + SERV_CONTACT_SENSOR, SERV_HUMIDITY_SENSOR, SERV_LEAK_SENSOR, + SERV_LIGHT_SENSOR, SERV_MOTION_SENSOR, SERV_OCCUPANCY_SENSOR, + SERV_SMOKE_SENSOR, SERV_TEMPERATURE_SENSOR) +from .util import ( + convert_to_float, temperature_to_homekit, density_to_air_quality) + +_LOGGER = logging.getLogger(__name__) + +BINARY_SENSOR_SERVICE_MAP = { + DEVICE_CLASS_CO2: (SERV_CARBON_DIOXIDE_SENSOR, + CHAR_CARBON_DIOXIDE_DETECTED), + DEVICE_CLASS_DOOR: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), + DEVICE_CLASS_GARAGE_DOOR: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), + DEVICE_CLASS_GAS: (SERV_CARBON_MONOXIDE_SENSOR, + CHAR_CARBON_MONOXIDE_DETECTED), + DEVICE_CLASS_MOISTURE: (SERV_LEAK_SENSOR, CHAR_LEAK_DETECTED), + DEVICE_CLASS_MOTION: (SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED), + DEVICE_CLASS_OCCUPANCY: (SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED), + DEVICE_CLASS_OPENING: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), + DEVICE_CLASS_SMOKE: (SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED), + DEVICE_CLASS_WINDOW: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE)} + + +@TYPES.register('TemperatureSensor') +class TemperatureSensor(HomeAccessory): + """Generate a TemperatureSensor accessory for a temperature sensor. + + Sensor entity must return temperature in °C, °F. + """ + + def __init__(self, *args): + """Initialize a TemperatureSensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + serv_temp = self.add_preload_service(SERV_TEMPERATURE_SENSOR) + self.char_temp = serv_temp.configure_char( + CHAR_CURRENT_TEMPERATURE, value=0, properties=PROP_CELSIUS) + self.unit = None + + def update_state(self, new_state): + """Update temperature after state changed.""" + unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) + temperature = convert_to_float(new_state.state) + if temperature: + temperature = temperature_to_homekit(temperature, unit) + self.char_temp.set_value(temperature) + _LOGGER.debug('%s: Current temperature set to %d°C', + self.entity_id, temperature) + + +@TYPES.register('HumiditySensor') +class HumiditySensor(HomeAccessory): + """Generate a HumiditySensor accessory as humidity sensor.""" + + def __init__(self, *args): + """Initialize a HumiditySensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + serv_humidity = self.add_preload_service(SERV_HUMIDITY_SENSOR) + self.char_humidity = serv_humidity.configure_char( + CHAR_CURRENT_HUMIDITY, value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + humidity = convert_to_float(new_state.state) + if humidity: + self.char_humidity.set_value(humidity) + _LOGGER.debug('%s: Percent set to %d%%', + self.entity_id, humidity) + + +@TYPES.register('AirQualitySensor') +class AirQualitySensor(HomeAccessory): + """Generate a AirQualitySensor accessory as air quality sensor.""" + + def __init__(self, *args): + """Initialize a AirQualitySensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_air_quality = self.add_preload_service( + SERV_AIR_QUALITY_SENSOR, [CHAR_AIR_PARTICULATE_DENSITY]) + self.char_quality = serv_air_quality.configure_char( + CHAR_AIR_QUALITY, value=0) + self.char_density = serv_air_quality.configure_char( + CHAR_AIR_PARTICULATE_DENSITY, value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + density = convert_to_float(new_state.state) + if density: + self.char_density.set_value(density) + self.char_quality.set_value(density_to_air_quality(density)) + _LOGGER.debug('%s: Set to %d', self.entity_id, density) + + +@TYPES.register('CarbonDioxideSensor') +class CarbonDioxideSensor(HomeAccessory): + """Generate a CarbonDioxideSensor accessory as CO2 sensor.""" + + def __init__(self, *args): + """Initialize a CarbonDioxideSensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_co2 = self.add_preload_service(SERV_CARBON_DIOXIDE_SENSOR, [ + CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL]) + self.char_co2 = serv_co2.configure_char( + CHAR_CARBON_DIOXIDE_LEVEL, value=0) + self.char_peak = serv_co2.configure_char( + CHAR_CARBON_DIOXIDE_PEAK_LEVEL, value=0) + self.char_detected = serv_co2.configure_char( + CHAR_CARBON_DIOXIDE_DETECTED, value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + co2 = convert_to_float(new_state.state) + if co2: + self.char_co2.set_value(co2) + if co2 > self.char_peak.value: + self.char_peak.set_value(co2) + self.char_detected.set_value(co2 > 1000) + _LOGGER.debug('%s: Set to %d', self.entity_id, co2) + + +@TYPES.register('LightSensor') +class LightSensor(HomeAccessory): + """Generate a LightSensor accessory as light sensor.""" + + def __init__(self, *args): + """Initialize a LightSensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_light = self.add_preload_service(SERV_LIGHT_SENSOR) + self.char_light = serv_light.configure_char( + CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + luminance = convert_to_float(new_state.state) + if luminance: + self.char_light.set_value(luminance) + _LOGGER.debug('%s: Set to %d', self.entity_id, luminance) + + +@TYPES.register('BinarySensor') +class BinarySensor(HomeAccessory): + """Generate a BinarySensor accessory as binary sensor.""" + + def __init__(self, *args): + """Initialize a BinarySensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + device_class = self.hass.states.get(self.entity_id).attributes \ + .get(ATTR_DEVICE_CLASS) + service_char = BINARY_SENSOR_SERVICE_MAP[device_class] \ + if device_class in BINARY_SENSOR_SERVICE_MAP \ + else BINARY_SENSOR_SERVICE_MAP[DEVICE_CLASS_OCCUPANCY] + + service = self.add_preload_service(service_char[0]) + self.char_detected = service.configure_char(service_char[1], value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + state = new_state.state + detected = (state == STATE_ON) or (state == STATE_HOME) + self.char_detected.set_value(detected) + _LOGGER.debug('%s: Set to %d', self.entity_id, detected) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py new file mode 100644 index 00000000000..c8bf8c7ad7c --- /dev/null +++ b/homeassistant/components/homekit/type_switches.py @@ -0,0 +1,82 @@ +"""Class to hold all switch accessories.""" +import logging + +from pyhap.const import CATEGORY_OUTLET, CATEGORY_SWITCH + +from homeassistant.components.switch import DOMAIN as SWITCH +from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_ON) +from homeassistant.core import split_entity_id + +from . import TYPES +from .accessories import HomeAccessory +from .const import CHAR_ON, CHAR_OUTLET_IN_USE, SERV_OUTLET, SERV_SWITCH + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register('Outlet') +class Outlet(HomeAccessory): + """Generate an Outlet accessory.""" + + def __init__(self, *args): + """Initialize an Outlet accessory object.""" + super().__init__(*args, category=CATEGORY_OUTLET) + self.flag_target_state = False + + serv_outlet = self.add_preload_service(SERV_OUTLET) + self.char_on = serv_outlet.configure_char( + CHAR_ON, value=False, setter_callback=self.set_state) + self.char_outlet_in_use = serv_outlet.configure_char( + CHAR_OUTLET_IN_USE, value=True) + + def set_state(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state to %s', + self.entity_id, value) + self.flag_target_state = True + params = {ATTR_ENTITY_ID: self.entity_id} + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + self.hass.services.call(SWITCH, service, params) + + def update_state(self, new_state): + """Update switch state after state changed.""" + current_state = (new_state.state == STATE_ON) + if not self.flag_target_state: + _LOGGER.debug('%s: Set current state to %s', + self.entity_id, current_state) + self.char_on.set_value(current_state) + self.flag_target_state = False + + +@TYPES.register('Switch') +class Switch(HomeAccessory): + """Generate a Switch accessory.""" + + def __init__(self, *args): + """Initialize a Switch accessory object.""" + super().__init__(*args, category=CATEGORY_SWITCH) + self._domain = split_entity_id(self.entity_id)[0] + self.flag_target_state = False + + serv_switch = self.add_preload_service(SERV_SWITCH) + self.char_on = serv_switch.configure_char( + CHAR_ON, value=False, setter_callback=self.set_state) + + def set_state(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state to %s', + self.entity_id, value) + self.flag_target_state = True + params = {ATTR_ENTITY_ID: self.entity_id} + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + self.hass.services.call(self._domain, service, params) + + def update_state(self, new_state): + """Update switch state after state changed.""" + current_state = (new_state.state == STATE_ON) + if not self.flag_target_state: + _LOGGER.debug('%s: Set current state to %s', + self.entity_id, current_state) + self.char_on.set_value(current_state) + self.flag_target_state = False diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py new file mode 100644 index 00000000000..73a29990fba --- /dev/null +++ b/homeassistant/components/homekit/type_thermostats.py @@ -0,0 +1,261 @@ +"""Class to hold all thermostat accessories.""" +import logging + +from pyhap.const import CATEGORY_THERMOSTAT + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, ATTR_MAX_TEMP, ATTR_MIN_TEMP, + ATTR_OPERATION_LIST, ATTR_OPERATION_MODE, + ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, + DOMAIN, SERVICE_SET_TEMPERATURE, SERVICE_SET_OPERATION_MODE, STATE_AUTO, + STATE_COOL, STATE_HEAT, SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE_HIGH, + SUPPORT_TARGET_TEMPERATURE_LOW) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) + +from . import TYPES +from .accessories import debounce, HomeAccessory +from .const import ( + CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_CURRENT_HEATING_COOLING, + CHAR_CURRENT_TEMPERATURE, CHAR_TARGET_HEATING_COOLING, + CHAR_HEATING_THRESHOLD_TEMPERATURE, CHAR_TARGET_TEMPERATURE, + CHAR_TEMP_DISPLAY_UNITS, PROP_MAX_VALUE, PROP_MIN_VALUE, SERV_THERMOSTAT) +from .util import temperature_to_homekit, temperature_to_states + +_LOGGER = logging.getLogger(__name__) + +UNIT_HASS_TO_HOMEKIT = {TEMP_CELSIUS: 0, TEMP_FAHRENHEIT: 1} +UNIT_HOMEKIT_TO_HASS = {c: s for s, c in UNIT_HASS_TO_HOMEKIT.items()} +HC_HASS_TO_HOMEKIT = {STATE_OFF: 0, STATE_HEAT: 1, + STATE_COOL: 2, STATE_AUTO: 3} +HC_HOMEKIT_TO_HASS = {c: s for s, c in HC_HASS_TO_HOMEKIT.items()} + +SUPPORT_TEMP_RANGE = SUPPORT_TARGET_TEMPERATURE_LOW | \ + SUPPORT_TARGET_TEMPERATURE_HIGH + + +@TYPES.register('Thermostat') +class Thermostat(HomeAccessory): + """Generate a Thermostat accessory for a climate.""" + + def __init__(self, *args): + """Initialize a Thermostat accessory object.""" + super().__init__(*args, category=CATEGORY_THERMOSTAT) + self._unit = self.hass.config.units.temperature_unit + self.support_power_state = False + self.heat_cool_flag_target_state = False + self.temperature_flag_target_state = False + self.coolingthresh_flag_target_state = False + self.heatingthresh_flag_target_state = False + min_temp, max_temp = self.get_temperature_range() + + # Add additional characteristics if auto mode is supported + self.chars = [] + features = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if features & SUPPORT_ON_OFF: + self.support_power_state = True + if features & SUPPORT_TEMP_RANGE: + self.chars.extend((CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_HEATING_THRESHOLD_TEMPERATURE)) + + serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars) + + # Current and target mode characteristics + self.char_current_heat_cool = serv_thermostat.configure_char( + CHAR_CURRENT_HEATING_COOLING, value=0) + self.char_target_heat_cool = serv_thermostat.configure_char( + CHAR_TARGET_HEATING_COOLING, value=0, + setter_callback=self.set_heat_cool) + + # Current and target temperature characteristics + self.char_current_temp = serv_thermostat.configure_char( + CHAR_CURRENT_TEMPERATURE, value=21.0) + self.char_target_temp = serv_thermostat.configure_char( + CHAR_TARGET_TEMPERATURE, value=21.0, + properties={PROP_MIN_VALUE: min_temp, + PROP_MAX_VALUE: max_temp}, + setter_callback=self.set_target_temperature) + + # Display units characteristic + self.char_display_units = serv_thermostat.configure_char( + CHAR_TEMP_DISPLAY_UNITS, value=0) + + # If the device supports it: high and low temperature characteristics + self.char_cooling_thresh_temp = None + self.char_heating_thresh_temp = None + if CHAR_COOLING_THRESHOLD_TEMPERATURE in self.chars: + self.char_cooling_thresh_temp = serv_thermostat.configure_char( + CHAR_COOLING_THRESHOLD_TEMPERATURE, value=23.0, + properties={PROP_MIN_VALUE: min_temp, + PROP_MAX_VALUE: max_temp}, + setter_callback=self.set_cooling_threshold) + if CHAR_HEATING_THRESHOLD_TEMPERATURE in self.chars: + self.char_heating_thresh_temp = serv_thermostat.configure_char( + CHAR_HEATING_THRESHOLD_TEMPERATURE, value=19.0, + properties={PROP_MIN_VALUE: min_temp, + PROP_MAX_VALUE: max_temp}, + setter_callback=self.set_heating_threshold) + + def get_temperature_range(self): + """Return min and max temperature range.""" + max_temp = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_MAX_TEMP) + max_temp = temperature_to_homekit(max_temp, self._unit) if max_temp \ + else DEFAULT_MAX_TEMP + + min_temp = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_MIN_TEMP) + min_temp = temperature_to_homekit(min_temp, self._unit) if min_temp \ + else DEFAULT_MIN_TEMP + + return min_temp, max_temp + + def set_heat_cool(self, value): + """Move operation mode to value if call came from HomeKit.""" + if value in HC_HOMEKIT_TO_HASS: + _LOGGER.debug('%s: Set heat-cool to %d', self.entity_id, value) + self.heat_cool_flag_target_state = True + hass_value = HC_HOMEKIT_TO_HASS[value] + if self.support_power_state is True: + params = {ATTR_ENTITY_ID: self.entity_id} + if hass_value == STATE_OFF: + self.hass.services.call(DOMAIN, SERVICE_TURN_OFF, params) + return + else: + self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) + params = {ATTR_ENTITY_ID: self.entity_id, + ATTR_OPERATION_MODE: hass_value} + self.hass.services.call(DOMAIN, SERVICE_SET_OPERATION_MODE, params) + + @debounce + def set_cooling_threshold(self, value): + """Set cooling threshold temp to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set cooling threshold temperature to %.2f°C', + self.entity_id, value) + self.coolingthresh_flag_target_state = True + low = self.char_heating_thresh_temp.value + params = { + ATTR_ENTITY_ID: self.entity_id, + ATTR_TARGET_TEMP_HIGH: temperature_to_states(value, self._unit), + ATTR_TARGET_TEMP_LOW: temperature_to_states(low, self._unit)} + self.hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, params) + + @debounce + def set_heating_threshold(self, value): + """Set heating threshold temp to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set heating threshold temperature to %.2f°C', + self.entity_id, value) + self.heatingthresh_flag_target_state = True + high = self.char_cooling_thresh_temp.value + params = { + ATTR_ENTITY_ID: self.entity_id, + ATTR_TARGET_TEMP_HIGH: temperature_to_states(high, self._unit), + ATTR_TARGET_TEMP_LOW: temperature_to_states(value, self._unit)} + self.hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, params) + + @debounce + def set_target_temperature(self, value): + """Set target temperature to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set target temperature to %.2f°C', + self.entity_id, value) + self.temperature_flag_target_state = True + params = { + ATTR_ENTITY_ID: self.entity_id, + ATTR_TEMPERATURE: temperature_to_states(value, self._unit)} + self.hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, params) + + def update_state(self, new_state): + """Update security state after state changed.""" + # Update current temperature + current_temp = new_state.attributes.get(ATTR_CURRENT_TEMPERATURE) + if isinstance(current_temp, (int, float)): + current_temp = temperature_to_homekit(current_temp, self._unit) + self.char_current_temp.set_value(current_temp) + + # Update target temperature + target_temp = new_state.attributes.get(ATTR_TEMPERATURE) + if isinstance(target_temp, (int, float)): + target_temp = temperature_to_homekit(target_temp, self._unit) + if not self.temperature_flag_target_state: + self.char_target_temp.set_value(target_temp) + self.temperature_flag_target_state = False + + # Update cooling threshold temperature if characteristic exists + if self.char_cooling_thresh_temp: + cooling_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) + if isinstance(cooling_thresh, (int, float)): + cooling_thresh = temperature_to_homekit(cooling_thresh, + self._unit) + if not self.coolingthresh_flag_target_state: + self.char_cooling_thresh_temp.set_value(cooling_thresh) + self.coolingthresh_flag_target_state = False + + # Update heating threshold temperature if characteristic exists + if self.char_heating_thresh_temp: + heating_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_LOW) + if isinstance(heating_thresh, (int, float)): + heating_thresh = temperature_to_homekit(heating_thresh, + self._unit) + if not self.heatingthresh_flag_target_state: + self.char_heating_thresh_temp.set_value(heating_thresh) + self.heatingthresh_flag_target_state = False + + # Update display units + if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT: + self.char_display_units.set_value(UNIT_HASS_TO_HOMEKIT[self._unit]) + + # Update target operation mode + operation_mode = new_state.attributes.get(ATTR_OPERATION_MODE) + if self.support_power_state is True and new_state.state == STATE_OFF: + self.char_target_heat_cool.set_value( + HC_HASS_TO_HOMEKIT[STATE_OFF]) + elif operation_mode and operation_mode in HC_HASS_TO_HOMEKIT: + if not self.heat_cool_flag_target_state: + self.char_target_heat_cool.set_value( + HC_HASS_TO_HOMEKIT[operation_mode]) + self.heat_cool_flag_target_state = False + + # Set current operation mode based on temperatures and target mode + if self.support_power_state is True and new_state.state == STATE_OFF: + current_operation_mode = STATE_OFF + elif operation_mode == STATE_HEAT: + if isinstance(target_temp, float) and current_temp < target_temp: + current_operation_mode = STATE_HEAT + else: + current_operation_mode = STATE_OFF + elif operation_mode == STATE_COOL: + if isinstance(target_temp, float) and current_temp > target_temp: + current_operation_mode = STATE_COOL + else: + current_operation_mode = STATE_OFF + elif operation_mode == STATE_AUTO: + # Check if auto is supported + if self.char_cooling_thresh_temp: + lower_temp = self.char_heating_thresh_temp.value + upper_temp = self.char_cooling_thresh_temp.value + if current_temp < lower_temp: + current_operation_mode = STATE_HEAT + elif current_temp > upper_temp: + current_operation_mode = STATE_COOL + else: + current_operation_mode = STATE_OFF + else: + # Check if heating or cooling are supported + heat = STATE_HEAT in new_state.attributes[ATTR_OPERATION_LIST] + cool = STATE_COOL in new_state.attributes[ATTR_OPERATION_LIST] + if isinstance(target_temp, float) and \ + current_temp < target_temp and heat: + current_operation_mode = STATE_HEAT + elif isinstance(target_temp, float) and \ + current_temp > target_temp and cool: + current_operation_mode = STATE_COOL + else: + current_operation_mode = STATE_OFF + else: + current_operation_mode = STATE_OFF + + self.char_current_heat_cool.set_value( + HC_HASS_TO_HOMEKIT[current_operation_mode]) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py new file mode 100644 index 00000000000..6a43a0c6228 --- /dev/null +++ b/homeassistant/components/homekit/util.py @@ -0,0 +1,151 @@ +"""Collection of useful functions for the HomeKit component.""" +import logging + +import voluptuous as vol + +import homeassistant.components.media_player as media_player +from homeassistant.core import split_entity_id +from homeassistant.const import ( + ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv +import homeassistant.util.temperature as temp_util +from .const import ( + CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF, + FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, TYPE_OUTLET, + TYPE_SWITCH) + +_LOGGER = logging.getLogger(__name__) + + +BASIC_INFO_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME): cv.string, +}) + +FEATURE_SCHEMA = BASIC_INFO_SCHEMA.extend({ + vol.Optional(CONF_FEATURE_LIST, default=None): cv.ensure_list, +}) + + +CODE_SCHEMA = BASIC_INFO_SCHEMA.extend({ + vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string), +}) + +MEDIA_PLAYER_SCHEMA = vol.Schema({ + vol.Required(CONF_FEATURE): vol.All( + cv.string, vol.In((FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE))), +}) + +SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend({ + vol.Optional(CONF_TYPE, default=TYPE_SWITCH): vol.All( + cv.string, vol.In((TYPE_OUTLET, TYPE_SWITCH))), +}) + + +def validate_entity_config(values): + """Validate config entry for CONF_ENTITY.""" + entities = {} + for entity_id, config in values.items(): + entity = cv.entity_id(entity_id) + domain, _ = split_entity_id(entity) + + if not isinstance(config, dict): + raise vol.Invalid('The configuration for {} must be ' + ' a dictionary.'.format(entity)) + + if domain in ('alarm_control_panel', 'lock'): + config = CODE_SCHEMA(config) + + elif domain == media_player.DOMAIN: + config = FEATURE_SCHEMA(config) + feature_list = {} + for feature in config[CONF_FEATURE_LIST]: + params = MEDIA_PLAYER_SCHEMA(feature) + key = params.pop(CONF_FEATURE) + if key in feature_list: + raise vol.Invalid('A feature can be added only once for {}' + .format(entity)) + feature_list[key] = params + config[CONF_FEATURE_LIST] = feature_list + + elif domain == 'switch': + config = SWITCH_TYPE_SCHEMA(config) + + else: + config = BASIC_INFO_SCHEMA(config) + + entities[entity] = config + return entities + + +def validate_media_player_features(state, feature_list): + """Validate features for media players.""" + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + supported_modes = [] + if features & (media_player.SUPPORT_TURN_ON | + media_player.SUPPORT_TURN_OFF): + supported_modes.append(FEATURE_ON_OFF) + if features & (media_player.SUPPORT_PLAY | media_player.SUPPORT_PAUSE): + supported_modes.append(FEATURE_PLAY_PAUSE) + if features & (media_player.SUPPORT_PLAY | media_player.SUPPORT_STOP): + supported_modes.append(FEATURE_PLAY_STOP) + if features & media_player.SUPPORT_VOLUME_MUTE: + supported_modes.append(FEATURE_TOGGLE_MUTE) + + error_list = [] + for feature in feature_list: + if feature not in supported_modes: + error_list.append(feature) + + if error_list: + _LOGGER.error("%s does not support features: %s", + state.entity_id, error_list) + return False + return True + + +def show_setup_message(hass, pincode): + """Display persistent notification with setup information.""" + pin = pincode.decode() + _LOGGER.info('Pincode: %s', pin) + message = 'To setup Home Assistant in the Home App, enter the ' \ + 'following code:\n### {}'.format(pin) + hass.components.persistent_notification.create( + message, 'HomeKit Setup', HOMEKIT_NOTIFY_ID) + + +def dismiss_setup_message(hass): + """Dismiss persistent notification and remove QR code.""" + hass.components.persistent_notification.dismiss(HOMEKIT_NOTIFY_ID) + + +def convert_to_float(state): + """Return float of state, catch errors.""" + try: + return float(state) + except (ValueError, TypeError): + return None + + +def temperature_to_homekit(temperature, unit): + """Convert temperature to Celsius for HomeKit.""" + return round(temp_util.convert(temperature, unit, TEMP_CELSIUS), 1) + + +def temperature_to_states(temperature, unit): + """Convert temperature back from Celsius to Home Assistant unit.""" + return round(temp_util.convert(temperature, TEMP_CELSIUS, unit), 1) + + +def density_to_air_quality(density): + """Map PM2.5 density to HomeKit AirQuality level.""" + if density <= 35: + return 1 + elif density <= 75: + return 2 + elif density <= 115: + return 3 + elif density <= 150: + return 4 + return 5 diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py new file mode 100644 index 00000000000..e36e7439e09 --- /dev/null +++ b/homeassistant/components/homekit_controller/__init__.py @@ -0,0 +1,249 @@ +""" +Support for Homekit device discovery. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/homekit_controller/ +""" +import http +import json +import logging +import os +import uuid + +from homeassistant.components.discovery import SERVICE_HOMEKIT +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['homekit==0.6'] + +DOMAIN = 'homekit_controller' +HOMEKIT_DIR = '.homekit' + +# Mapping from Homekit type to component. +HOMEKIT_ACCESSORY_DISPATCH = { + 'lightbulb': 'light', + 'outlet': 'switch', +} + +KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) +KNOWN_DEVICES = "{}-devices".format(DOMAIN) + +_LOGGER = logging.getLogger(__name__) + + +def homekit_http_send(self, message_body=None, encode_chunked=False): + r"""Send the currently buffered request and clear the buffer. + + Appends an extra \r\n to the buffer. + A message_body may be specified, to be appended to the request. + """ + self._buffer.extend((b"", b"")) + msg = b"\r\n".join(self._buffer) + del self._buffer[:] + + if message_body is not None: + msg = msg + message_body + + self.send(msg) + + +def get_serial(accessory): + """Obtain the serial number of a HomeKit device.""" + # pylint: disable=import-error + import homekit + for service in accessory['services']: + if homekit.ServicesTypes.get_short(service['type']) != \ + 'accessory-information': + continue + for characteristic in service['characteristics']: + ctype = homekit.CharacteristicsTypes.get_short( + characteristic['type']) + if ctype != 'serial-number': + continue + return characteristic['value'] + return None + + +class HKDevice(): + """HomeKit device.""" + + def __init__(self, hass, host, port, model, hkid, config_num, config): + """Initialise a generic HomeKit device.""" + # pylint: disable=import-error + import homekit + + _LOGGER.info("Setting up Homekit device %s", model) + self.hass = hass + self.host = host + self.port = port + self.model = model + self.hkid = hkid + self.config_num = config_num + self.config = config + self.configurator = hass.components.configurator + + data_dir = os.path.join(hass.config.path(), HOMEKIT_DIR) + if not os.path.isdir(data_dir): + os.mkdir(data_dir) + + self.pairing_file = os.path.join(data_dir, 'hk-{}'.format(hkid)) + self.pairing_data = homekit.load_pairing(self.pairing_file) + + # Monkey patch httpclient for increased compatibility + # pylint: disable=protected-access + http.client.HTTPConnection._send_output = homekit_http_send + + self.conn = http.client.HTTPConnection(self.host, port=self.port) + if self.pairing_data is not None: + self.accessory_setup() + else: + self.configure() + + def accessory_setup(self): + """Handle setup of a HomeKit accessory.""" + # pylint: disable=import-error + import homekit + self.controllerkey, self.accessorykey = \ + homekit.get_session_keys(self.conn, self.pairing_data) + self.securecon = homekit.SecureHttp(self.conn.sock, + self.accessorykey, + self.controllerkey) + response = self.securecon.get('/accessories') + data = json.loads(response.read().decode()) + for accessory in data['accessories']: + serial = get_serial(accessory) + if serial in self.hass.data[KNOWN_ACCESSORIES]: + continue + self.hass.data[KNOWN_ACCESSORIES][serial] = self + aid = accessory['aid'] + for service in accessory['services']: + service_info = {'serial': serial, + 'aid': aid, + 'iid': service['iid']} + devtype = homekit.ServicesTypes.get_short(service['type']) + _LOGGER.debug("Found %s", devtype) + component = HOMEKIT_ACCESSORY_DISPATCH.get(devtype, None) + if component is not None: + discovery.load_platform(self.hass, component, DOMAIN, + service_info, self.config) + + def device_config_callback(self, callback_data): + """Handle initial pairing.""" + # pylint: disable=import-error + import homekit + pairing_id = str(uuid.uuid4()) + code = callback_data.get('code').strip() + try: + self.pairing_data = homekit.perform_pair_setup(self.conn, code, + pairing_id) + except homekit.exception.UnavailableError: + error_msg = "This accessory is already paired to another device. \ + Please reset the accessory and try again." + _configurator = self.hass.data[DOMAIN+self.hkid] + self.configurator.notify_errors(_configurator, error_msg) + return + except homekit.exception.AuthenticationError: + error_msg = "Incorrect HomeKit code for {}. Please check it and \ + try again.".format(self.model) + _configurator = self.hass.data[DOMAIN+self.hkid] + self.configurator.notify_errors(_configurator, error_msg) + return + except homekit.exception.UnknownError: + error_msg = "Received an unknown error. Please file a bug." + _configurator = self.hass.data[DOMAIN+self.hkid] + self.configurator.notify_errors(_configurator, error_msg) + raise + + if self.pairing_data is not None: + homekit.save_pairing(self.pairing_file, self.pairing_data) + _configurator = self.hass.data[DOMAIN+self.hkid] + self.configurator.request_done(_configurator) + self.accessory_setup() + else: + error_msg = "Unable to pair, please try again" + _configurator = self.hass.data[DOMAIN+self.hkid] + self.configurator.notify_errors(_configurator, error_msg) + + def configure(self): + """Obtain the pairing code for a HomeKit device.""" + description = "Please enter the HomeKit code for your {}".format( + self.model) + self.hass.data[DOMAIN+self.hkid] = \ + self.configurator.request_config(self.model, + self.device_config_callback, + description=description, + submit_caption="submit", + fields=[{'id': 'code', + 'name': 'HomeKit code', + 'type': 'string'}]) + + +class HomeKitEntity(Entity): + """Representation of a Home Assistant HomeKit device.""" + + def __init__(self, accessory, devinfo): + """Initialise a generic HomeKit device.""" + self._name = accessory.model + self._securecon = accessory.securecon + self._aid = devinfo['aid'] + self._iid = devinfo['iid'] + self._address = "homekit-{}-{}".format(devinfo['serial'], self._iid) + self._features = 0 + self._chars = {} + + def update(self): + """Obtain a HomeKit device's state.""" + response = self._securecon.get('/accessories') + data = json.loads(response.read().decode()) + for accessory in data['accessories']: + if accessory['aid'] != self._aid: + continue + for service in accessory['services']: + if service['iid'] != self._iid: + continue + self.update_characteristics(service['characteristics']) + break + + @property + def unique_id(self): + """Return the ID of this device.""" + return self._address + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + def update_characteristics(self, characteristics): + """Synchronise a HomeKit device state with Home Assistant.""" + raise NotImplementedError + + +# pylint: too-many-function-args +def setup(hass, config): + """Set up for Homekit devices.""" + def discovery_dispatch(service, discovery_info): + """Dispatcher for Homekit discovery events.""" + # model, id + host = discovery_info['host'] + port = discovery_info['port'] + model = discovery_info['properties']['md'] + hkid = discovery_info['properties']['id'] + config_num = int(discovery_info['properties']['c#']) + + # Only register a device once, but rescan if the config has changed + if hkid in hass.data[KNOWN_DEVICES]: + device = hass.data[KNOWN_DEVICES][hkid] + if config_num > device.config_num and \ + device.pairing_info is not None: + device.accessory_setup() + return + + _LOGGER.debug('Discovered unique device %s', hkid) + device = HKDevice(hass, host, port, model, hkid, config_num, config) + hass.data[KNOWN_DEVICES][hkid] = device + + hass.data[KNOWN_ACCESSORIES] = {} + hass.data[KNOWN_DEVICES] = {} + discovery.listen(hass, SERVICE_HOMEKIT, discovery_dispatch) + return True diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index b913b58864d..29303b551e2 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -13,17 +13,19 @@ import socket import voluptuous as vol from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, - CONF_HOSTS, CONF_HOST, ATTR_ENTITY_ID, STATE_UNKNOWN) + ATTR_ENTITY_ID, ATTR_NAME, CONF_HOST, CONF_HOSTS, CONF_PASSWORD, + CONF_PLATFORM, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) from homeassistant.helpers import discovery -from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity from homeassistant.loader import bind_hass -REQUIREMENTS = ['pyhomematic==0.1.39'] -DOMAIN = 'homematic' +REQUIREMENTS = ['pyhomematic==0.1.43'] + _LOGGER = logging.getLogger(__name__) +DOMAIN = 'homematic' + SCAN_INTERVAL_HUB = timedelta(seconds=300) SCAN_INTERVAL_VARIABLES = timedelta(seconds=30) @@ -33,11 +35,11 @@ DISCOVER_SENSORS = 'homematic.sensor' DISCOVER_BINARY_SENSORS = 'homematic.binary_sensor' DISCOVER_COVER = 'homematic.cover' DISCOVER_CLIMATE = 'homematic.climate' +DISCOVER_LOCKS = 'homematic.locks' ATTR_DISCOVER_DEVICES = 'devices' ATTR_PARAM = 'param' ATTR_CHANNEL = 'channel' -ATTR_NAME = 'name' ATTR_ADDRESS = 'address' ATTR_VALUE = 'value' ATTR_INTERFACE = 'interface' @@ -59,7 +61,7 @@ SERVICE_SET_INSTALL_MODE = 'set_install_mode' HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ 'Switch', 'SwitchPowermeter', 'IOSwitch', 'IPSwitch', 'RFSiren', - 'IPSwitchPowermeter', 'KeyMatic', 'HMWIOSwitch', 'Rain', 'EcoLogic'], + 'IPSwitchPowermeter', 'HMWIOSwitch', 'Rain', 'EcoLogic'], DISCOVER_LIGHTS: ['Dimmer', 'KeyDimmer', 'IPKeyDimmer'], DISCOVER_SENSORS: [ 'SwitchPowermeter', 'Motion', 'MotionV2', 'RemoteMotion', 'MotionIP', @@ -68,7 +70,8 @@ HM_DEVICE_TYPES = { 'WeatherStation', 'ThermostatWall2', 'TemperatureDiffSensor', 'TemperatureSensor', 'CO2Sensor', 'IPSwitchPowermeter', 'HMWIOSwitch', 'FillingLevel', 'ValveDrive', 'EcoLogic', 'IPThermostatWall', - 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat'], + 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', + 'IPWeatherSensor', 'RotaryHandleSensorIP'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', @@ -77,8 +80,9 @@ HM_DEVICE_TYPES = { 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', - 'WiredSensor', 'PresenceIP'], - DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'] + 'WiredSensor', 'PresenceIP', 'IPWeatherSensor'], + DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'], + DISCOVER_LOCKS: ['KeyMatic'] } HM_IGNORE_DISCOVERY_NODE = [ @@ -87,13 +91,14 @@ HM_IGNORE_DISCOVERY_NODE = [ ] HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS = { - 'ACTUAL_TEMPERATURE': ['IPAreaThermostat'], + 'ACTUAL_TEMPERATURE': ['IPAreaThermostat', 'IPWeatherSensor'], } HM_ATTRIBUTE_SUPPORT = { 'LOWBAT': ['battery', {0: 'High', 1: 'Low'}], 'LOW_BAT': ['battery', {0: 'High', 1: 'Low'}], 'ERROR': ['sabotage', {0: 'No', 1: 'Yes'}], + 'SABOTAGE': ['sabotage', {0: 'No', 1: 'Yes'}], 'RSSI_DEVICE': ['rssi', {}], 'VALVE_STATE': ['valve', {}], 'BATTERY_STATE': ['battery', {}], @@ -464,7 +469,8 @@ def _system_callback_handler(hass, config, src, *args): ('cover', DISCOVER_COVER), ('binary_sensor', DISCOVER_BINARY_SENSORS), ('sensor', DISCOVER_SENSORS), - ('climate', DISCOVER_CLIMATE)): + ('climate', DISCOVER_CLIMATE), + ('lock', DISCOVER_LOCKS)): # Get all devices of a specific type found_devices = _get_devices( hass, discovery_type, addresses, interface) diff --git a/homeassistant/components/homematicip_cloud.py b/homeassistant/components/homematicip_cloud.py new file mode 100644 index 00000000000..859841dfca6 --- /dev/null +++ b/homeassistant/components/homematicip_cloud.py @@ -0,0 +1,262 @@ +""" +Support for HomematicIP components. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/homematicip_cloud/ +""" + +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.entity import Entity +from homeassistant.core import callback + +REQUIREMENTS = ['homematicip==0.9.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'homematicip_cloud' + +COMPONENTS = [ + 'sensor', + 'binary_sensor', + 'switch', + 'light', + 'climate', +] + +CONF_NAME = 'name' +CONF_ACCESSPOINT = 'accesspoint' +CONF_AUTHTOKEN = 'authtoken' + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN, default=[]): vol.All(cv.ensure_list, [vol.Schema({ + vol.Optional(CONF_NAME): vol.Any(cv.string), + vol.Required(CONF_ACCESSPOINT): cv.string, + vol.Required(CONF_AUTHTOKEN): cv.string, + })]), +}, extra=vol.ALLOW_EXTRA) + +HMIP_ACCESS_POINT = 'Access Point' +HMIP_HUB = 'HmIP-HUB' + +ATTR_HOME_ID = 'home_id' +ATTR_HOME_NAME = 'home_name' +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_LABEL = 'device_label' +ATTR_STATUS_UPDATE = 'status_update' +ATTR_FIRMWARE_STATE = 'firmware_state' +ATTR_UNREACHABLE = 'unreachable' +ATTR_LOW_BATTERY = 'low_battery' +ATTR_MODEL_TYPE = 'model_type' +ATTR_GROUP_TYPE = 'group_type' +ATTR_DEVICE_RSSI = 'device_rssi' +ATTR_DUTY_CYCLE = 'duty_cycle' +ATTR_CONNECTED = 'connected' +ATTR_SABOTAGE = 'sabotage' +ATTR_OPERATION_LOCK = 'operation_lock' + + +async def async_setup(hass, config): + """Set up the HomematicIP component.""" + from homematicip.base.base_connection import HmipConnectionError + + hass.data.setdefault(DOMAIN, {}) + accesspoints = config.get(DOMAIN, []) + for conf in accesspoints: + _websession = async_get_clientsession(hass) + _hmip = HomematicipConnector(hass, conf, _websession) + try: + await _hmip.init() + except HmipConnectionError: + _LOGGER.error('Failed to connect to the HomematicIP server, %s.', + conf.get(CONF_ACCESSPOINT)) + return False + + home = _hmip.home + home.name = conf.get(CONF_NAME) + home.label = HMIP_ACCESS_POINT + home.modelType = HMIP_HUB + + hass.data[DOMAIN][home.id] = home + _LOGGER.info('Connected to the HomematicIP server, %s.', + conf.get(CONF_ACCESSPOINT)) + homeid = {ATTR_HOME_ID: home.id} + for component in COMPONENTS: + hass.async_add_job(async_load_platform(hass, component, DOMAIN, + homeid, config)) + + hass.loop.create_task(_hmip.connect()) + return True + + +class HomematicipConnector: + """Manages HomematicIP http and websocket connection.""" + + def __init__(self, hass, config, websession): + """Initialize HomematicIP cloud connection.""" + from homematicip.async.home import AsyncHome + + self._hass = hass + self._ws_close_requested = False + self._retry_task = None + self._tries = 0 + self._accesspoint = config.get(CONF_ACCESSPOINT) + _authtoken = config.get(CONF_AUTHTOKEN) + + self.home = AsyncHome(hass.loop, websession) + self.home.set_auth_token(_authtoken) + + self.home.on_update(self.async_update) + self._accesspoint_connected = True + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.close()) + + async def init(self): + """Initialize connection.""" + await self.home.init(self._accesspoint) + await self.home.get_current_state() + + @callback + def async_update(self, *args, **kwargs): + """Async update the home device. + + Triggered when the hmip HOME_CHANGED event has fired. + There are several occasions for this event to happen. + We are only interested to check whether the access point + is still connected. If not, device state changes cannot + be forwarded to hass. So if access point is disconnected all devices + are set to unavailable. + """ + if not self.home.connected: + _LOGGER.error( + "HMIP access point has lost connection with the cloud") + self._accesspoint_connected = False + self.set_all_to_unavailable() + elif not self._accesspoint_connected: + # Explicitly getting an update as device states might have + # changed during access point disconnect.""" + + job = self._hass.async_add_job(self.get_state()) + job.add_done_callback(self.get_state_finished) + + async def get_state(self): + """Update hmip state and tell hass.""" + await self.home.get_current_state() + self.update_all() + + def get_state_finished(self, future): + """Execute when get_state coroutine has finished.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + future.result() + except HmipConnectionError: + # Somehow connection could not recover. Will disconnect and + # so reconnect loop is taking over. + _LOGGER.error( + "updating state after himp access point reconnect failed.") + self._hass.async_add_job(self.home.disable_events()) + + def set_all_to_unavailable(self): + """Set all devices to unavailable and tell Hass.""" + for device in self.home.devices: + device.unreach = True + self.update_all() + + def update_all(self): + """Signal all devices to update their state.""" + for device in self.home.devices: + device.fire_update_event() + + async def _handle_connection(self): + """Handle websocket connection.""" + from homematicip.base.base_connection import HmipConnectionError + + await self.home.get_current_state() + hmip_events = await self.home.enable_events() + try: + await hmip_events + except HmipConnectionError: + return + + async def connect(self): + """Start websocket connection.""" + self._tries = 0 + while True: + await self._handle_connection() + if self._ws_close_requested: + break + self._ws_close_requested = False + self._tries += 1 + try: + self._retry_task = self._hass.async_add_job(asyncio.sleep( + 2 ** min(9, self._tries), loop=self._hass.loop)) + await self._retry_task + except asyncio.CancelledError: + break + _LOGGER.info('Reconnect (%s) to the HomematicIP cloud server.', + self._tries) + + async def close(self): + """Close the websocket connection.""" + self._ws_close_requested = True + if self._retry_task is not None: + self._retry_task.cancel() + await self.home.disable_events() + _LOGGER.info("Closed connection to HomematicIP cloud server.") + + +class HomematicipGenericDevice(Entity): + """Representation of an HomematicIP generic device.""" + + def __init__(self, home, device, post=None): + """Initialize the generic device.""" + self._home = home + self._device = device + self.post = post + _LOGGER.info('Setting up %s (%s)', self.name, + self._device.modelType) + + async def async_added_to_hass(self): + """Register callbacks.""" + self._device.on_update(self._device_changed) + + def _device_changed(self, json, **kwargs): + """Handle device state changes.""" + _LOGGER.debug('Event %s (%s)', self.name, self._device.modelType) + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the name of the generic device.""" + name = self._device.label + if self._home.name is not None: + name = "{} {}".format(self._home.name, name) + if self.post is not None: + name = "{} {}".format(name, self.post) + return name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def available(self): + """Device available.""" + return not self._device.unreach + + @property + def device_state_attributes(self): + """Return the state attributes of the generic device.""" + return { + ATTR_LOW_BATTERY: self._device.lowBat, + ATTR_MODEL_TYPE: self._device.modelType + } diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 4d313b5132e..17906157a6e 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -4,7 +4,6 @@ This module provides WSGI application to serve the Home Assistant API. For more details about this component, please refer to the documentation at https://home-assistant.io/components/http/ """ - from ipaddress import ip_network import logging import os @@ -32,7 +31,7 @@ from .static import ( from .const import KEY_AUTHENTICATED, KEY_REAL_IP # noqa from .view import HomeAssistantView # noqa -REQUIREMENTS = ['aiohttp_cors==0.6.0'] +REQUIREMENTS = ['aiohttp_cors==0.7.0'] DOMAIN = 'http' diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 65c70c37bd2..c4723abccee 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -32,17 +32,19 @@ def setup_auth(app, trusted_networks, api_password): if (HTTP_HEADER_HA_AUTH in request.headers and hmac.compare_digest( - api_password, request.headers[HTTP_HEADER_HA_AUTH])): + api_password.encode('utf-8'), + request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): # A valid auth header has been set authenticated = True elif (DATA_API_PASSWORD in request.query and - hmac.compare_digest(api_password, - request.query[DATA_API_PASSWORD])): + hmac.compare_digest( + api_password.encode('utf-8'), + request.query[DATA_API_PASSWORD].encode('utf-8'))): authenticated = True elif (hdrs.AUTHORIZATION in request.headers and - validate_authorization_header(api_password, request)): + await async_validate_auth_header(api_password, request)): authenticated = True elif _is_trusted_ip(request, trusted_networks): @@ -70,23 +72,43 @@ def _is_trusted_ip(request, trusted_networks): def validate_password(request, api_password): """Test if password is valid.""" return hmac.compare_digest( - api_password, request.app['hass'].http.api_password) + api_password.encode('utf-8'), + request.app['hass'].http.api_password.encode('utf-8')) -def validate_authorization_header(api_password, request): +async def async_validate_auth_header(api_password, request): """Test an authorization header if valid password.""" if hdrs.AUTHORIZATION not in request.headers: return False - auth_type, auth = request.headers.get(hdrs.AUTHORIZATION).split(' ', 1) - - if auth_type != 'Basic': + try: + auth_type, auth_val = \ + request.headers.get(hdrs.AUTHORIZATION).split(' ', 1) + except ValueError: + # If no space in authorization header return False - decoded = base64.b64decode(auth).decode('utf-8') - username, password = decoded.split(':', 1) + if auth_type == 'Basic': + decoded = base64.b64decode(auth_val).decode('utf-8') + try: + username, password = decoded.split(':', 1) + except ValueError: + # If no ':' in decoded + return False - if username != 'homeassistant': + if username != 'homeassistant': + return False + + return hmac.compare_digest(api_password.encode('utf-8'), + password.encode('utf-8')) + + if auth_type != 'Bearer': return False - return hmac.compare_digest(api_password, password) + hass = request.app['hass'] + access_token = hass.auth.async_get_access_token(auth_val) + if access_token is None: + return False + + request['hass_user'] = access_token.refresh_token.user + return True diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 81c6ea4bcfb..3de276564eb 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -51,12 +51,6 @@ class HomeAssistantView(object): data['code'] = message_code return self.json(data, status_code, headers=headers) - # pylint: disable=no-self-use - async def file(self, request, fil): - """Return a file.""" - assert isinstance(fil, str), 'only string paths allowed' - return web.FileResponse(fil) - def register(self, router): """Register the view with a router.""" assert self.url is not None, 'No url set for view' diff --git a/homeassistant/components/hue/.translations/bg.json b/homeassistant/components/hue/.translations/bg.json new file mode 100644 index 00000000000..276f5053bf7 --- /dev/null +++ b/homeassistant/components/hue/.translations/bg.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "\u0412\u0441\u0438\u0447\u043a\u0438 \u0431\u0430\u0437\u043e\u0432\u0438 \u0441\u0442\u0430\u043d\u0446\u0438\u0438 Philips Hue \u0441\u0430 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0438", + "already_configured": "\u0411\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f \u0435 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430", + "cannot_connect": "\u041d\u0435 \u043c\u043e\u0436\u0430 \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0436\u0435 \u0441 \u0431\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f", + "discover_timeout": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e \u0435 \u0434\u0430 \u0431\u044a\u0434\u0430\u0442 \u043e\u0442\u043a\u0440\u0438\u0442\u0438 \u0431\u0430\u0437\u043e\u0432\u0438 \u0441\u0442\u0430\u043d\u0446\u0438\u0438 \u043d\u0430 Philips Hue", + "no_bridges": "\u041d\u0435 \u0441\u0430 \u043e\u0442\u043a\u0440\u0438\u0442\u0438 \u0431\u0430\u0437\u043e\u0432\u0438 \u0441\u0442\u0430\u043d\u0446\u0438\u0438 \u043d\u0430 Philips Hue", + "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "error": { + "linking": "\u041f\u043e\u044f\u0432\u0438 \u0441\u0435 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435\u0442\u043e.", + "register_failed": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u0430, \u043c\u043e\u043b\u044f \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e" + }, + "step": { + "init": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f Philips Hue" + }, + "link": { + "description": "\u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f, \u0437\u0430 \u0434\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u0442\u0435 Philips Hue \u0441 Home Assistant. \n\n![\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430 \u0431\u0443\u0442\u043e\u043d\u0430 \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f](/static/images/config_philips_hue.jpg)", + "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u0445\u044a\u0431" + } + }, + "title": "\u0411\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f Philips Hue" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/cy.json b/homeassistant/components/hue/.translations/cy.json new file mode 100644 index 00000000000..f5476f73edb --- /dev/null +++ b/homeassistant/components/hue/.translations/cy.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "Mae holl bontydd Philips Hue eisoes wedi eu ffurfweddu", + "already_configured": "Pont eisoes wedi'i ffurfweddu", + "cannot_connect": "Methu cysylltu i'r bont", + "discover_timeout": "Methu darganfod pontydd Hue", + "no_bridges": "Dim pontydd Philips Hue wedi'i ddarganfod", + "unknown": "Digwyddodd gwall anhysbys" + }, + "error": { + "linking": "Digwyddodd gwall cysylltu anhysbys.", + "register_failed": "Wedi methu \u00e2 chofrestru, pl\u00eds ceisiwch eto" + }, + "step": { + "init": { + "data": { + "host": "Gwesteiwr" + }, + "title": "Dewiswch bont Hue" + }, + "link": { + "description": "Pwyswch y botwm ar y bont i gofrestru Philips Hue gyda Cynorthwydd Cartref.\n\n![Lleoliad botwm ar bont](/static/images/config_philips_hue.jpg)", + "title": "Hwb cyswllt" + } + }, + "title": "Pont Phillips Hue" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/da.json b/homeassistant/components/hue/.translations/da.json new file mode 100644 index 00000000000..3e5e2b1d3d7 --- /dev/null +++ b/homeassistant/components/hue/.translations/da.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "no_bridges": "Ingen Philips Hue bridge fundet" + }, + "step": { + "init": { + "data": { + "host": "V\u00e6rt" + }, + "title": "V\u00e6lg Hue bridge" + }, + "link": { + "title": "Link Hub" + } + }, + "title": "Philips Hue Bridge" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/de.json b/homeassistant/components/hue/.translations/de.json new file mode 100644 index 00000000000..d466488e9fc --- /dev/null +++ b/homeassistant/components/hue/.translations/de.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "Alle Philips Hue Bridges sind bereits konfiguriert", + "already_configured": "Bridge ist bereits konfiguriert", + "cannot_connect": "Verbindung zur Bridge nicht m\u00f6glich", + "discover_timeout": "Nicht in der Lage Hue Bridges zu entdecken", + "no_bridges": "Keine Philips Hue Bridges entdeckt", + "unknown": "Unbekannter Fehler ist aufgetreten" + }, + "error": { + "linking": "Unbekannter Link-Fehler aufgetreten.", + "register_failed": "Registrieren fehlgeschlagen, bitte versuche es erneut" + }, + "step": { + "init": { + "data": { + "host": "Host" + }, + "title": "W\u00e4hle eine Hue Bridge" + }, + "link": { + "description": "Dr\u00fccke den Knopf auf der Bridge, um Philips Hue mit Home Assistant zu registrieren.\n\n![Position des Buttons auf der Bridge](/static/images/config_philips_hue.jpg)", + "title": "Hub verbinden" + } + }, + "title": "Philips Hue Bridge" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/en.json b/homeassistant/components/hue/.translations/en.json index ee2e01fdb17..b0459ec3916 100644 --- a/homeassistant/components/hue/.translations/en.json +++ b/homeassistant/components/hue/.translations/en.json @@ -2,8 +2,11 @@ "config": { "abort": { "all_configured": "All Philips Hue bridges are already configured", + "already_configured": "Bridge is already configured", + "cannot_connect": "Unable to connect to the bridge", "discover_timeout": "Unable to discover Hue bridges", - "no_bridges": "No Philips Hue bridges discovered" + "no_bridges": "No Philips Hue bridges discovered", + "unknown": "Unknown error occurred" }, "error": { "linking": "Unknown linking error occurred.", @@ -23,4 +26,4 @@ }, "title": "Philips Hue Bridge" } -} +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/es.json b/homeassistant/components/hue/.translations/es.json new file mode 100644 index 00000000000..d58469af044 --- /dev/null +++ b/homeassistant/components/hue/.translations/es.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "unknown": "Se produjo un error desconocido" + }, + "error": { + "linking": "Se produjo un error de enlace desconocido.", + "register_failed": "No se pudo registrar, intente de nuevo" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/hu.json b/homeassistant/components/hue/.translations/hu.json new file mode 100644 index 00000000000..a4032dcbcfc --- /dev/null +++ b/homeassistant/components/hue/.translations/hu.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "all_configured": "M\u00e1r minden Philips Hue bridge konfigur\u00e1lt", + "already_configured": "A bridge m\u00e1r konfigur\u00e1lt", + "cannot_connect": "Nem siker\u00fclt csatlakozni a bridge-hez.", + "discover_timeout": "Nem tal\u00e1ltam a Hue bridget", + "no_bridges": "Nem tal\u00e1ltam Philips Hue bridget", + "unknown": "Ismeretlen hiba t\u00f6rt\u00e9nt" + }, + "error": { + "linking": "Ismeretlen \u00f6sszekapcsol\u00e1si hiba t\u00f6rt\u00e9nt.", + "register_failed": "Regisztr\u00e1ci\u00f3 nem siker\u00fclt, k\u00e9rem pr\u00f3b\u00e1lja \u00fajra" + }, + "step": { + "init": { + "data": { + "host": "H\u00e1zigazda (Host)" + }, + "title": "V\u00e1lassz Hue bridge-t" + }, + "link": { + "title": "Kapcsol\u00f3d\u00e1s a hubhoz" + } + }, + "title": "Philips Hue Bridge" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/it.json b/homeassistant/components/hue/.translations/it.json new file mode 100644 index 00000000000..2c7a8c1924d --- /dev/null +++ b/homeassistant/components/hue/.translations/it.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "all_configured": "Tutti i bridge Philips Hue sono gi\u00e0 configurati", + "discover_timeout": "Impossibile trovare i bridge Hue", + "no_bridges": "Nessun bridge Hue di Philips trovato" + }, + "title": "Philips Hue Bridge" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/ko.json b/homeassistant/components/hue/.translations/ko.json new file mode 100644 index 00000000000..47306a35414 --- /dev/null +++ b/homeassistant/components/hue/.translations/ko.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "\ubaa8\ub4e0 \ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4", + "already_configured": "\ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4", + "cannot_connect": "\ube0c\ub9ac\uc9c0\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "discover_timeout": "Hue \ube0c\ub9bf\uc9c0\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "no_bridges": "\ubc1c\uacac\ub41c \ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", + "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "linking": "\uc54c \uc218\uc5c6\ub294 \uc5f0\uacb0 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "register_failed": "\ub4f1\ub85d\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\ubcf4\uc138\uc694" + }, + "step": { + "init": { + "data": { + "host": "\ud638\uc2a4\ud2b8" + }, + "title": "Hue \ube0c\ub9bf\uc9c0 \uc120\ud0dd" + }, + "link": { + "description": "\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc\uc744 \ub20c\ub7ec \ud544\ub9bd\uc2a4 Hue\ub97c Home Assistant\uc5d0 \ub4f1\ub85d\ud558\uc138\uc694.\n\n![\ube0c\ub9bf\uc9c0 \ubc84\ud2bc \uc704\uce58](/static/images/config_philips_hue.jpg)", + "title": "\ud5c8\ube0c \uc5f0\uacb0" + } + }, + "title": "\ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/lb.json b/homeassistant/components/hue/.translations/lb.json new file mode 100644 index 00000000000..c4ad10da278 --- /dev/null +++ b/homeassistant/components/hue/.translations/lb.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "All Philips Hue Bridge si scho\u00a0konfigur\u00e9iert", + "already_configured": "Bridge ass scho konfigur\u00e9iert", + "cannot_connect": "Keng Verbindung mat der bridge m\u00e9iglech", + "discover_timeout": "Keng Hue bridge fonnt", + "no_bridges": "Keng Philips Hue Bridge fonnt", + "unknown": "Onbekannten Feeler opgetrueden" + }, + "error": { + "linking": "Onbekannte Liaisoun's Feeler opgetrueden", + "register_failed": "Feeler beim registr\u00e9ieren, prob\u00e9iert w.e.g. nach emol" + }, + "step": { + "init": { + "data": { + "host": "Host" + }, + "title": "Hue Bridge auswielen" + }, + "link": { + "description": "Dr\u00e9ckt de Kn\u00e4ppchen un der Bridge fir den Philips Hue mam Home Assistant ze registr\u00e9ieren.\n\n![Kn\u00e4ppchen un der Bridge](/static/images/config_philips_hue.jpg)", + "title": "Link Hub" + } + }, + "title": "Philips Hue Bridge" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/nl.json b/homeassistant/components/hue/.translations/nl.json new file mode 100644 index 00000000000..88c611b1633 --- /dev/null +++ b/homeassistant/components/hue/.translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "Alle Philips Hue bridges zijn al geconfigureerd", + "already_configured": "Bridge is al geconfigureerd", + "cannot_connect": "Kan geen verbinding maken met bridge", + "discover_timeout": "Hue bridges kunnen niet worden gevonden", + "no_bridges": "Geen Philips Hue bridges ontdekt", + "unknown": "Onbekende fout opgetreden" + }, + "error": { + "linking": "Er is een onbekende verbindingsfout opgetreden.", + "register_failed": "Registratie is mislukt, probeer het opnieuw" + }, + "step": { + "init": { + "data": { + "host": "Host" + }, + "title": "Kies Hue bridge" + }, + "link": { + "description": "Druk op de knop van de bridge om Philips Hue te registreren met Home Assistant. ![Locatie van de knop op bridge] (/static/images/config_philips_hue.jpg)", + "title": "Link Hub" + } + }, + "title": "Philips Hue Bridge" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/no.json b/homeassistant/components/hue/.translations/no.json new file mode 100644 index 00000000000..309e9f6a299 --- /dev/null +++ b/homeassistant/components/hue/.translations/no.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "Alle Philips Hue Bridger er allerede konfigurert", + "already_configured": "Bridge er allerede konfigurert", + "cannot_connect": "Kan ikke koble til Bridge", + "discover_timeout": "Kunne ikke oppdage Hue Bridger", + "no_bridges": "Ingen Philips Hue Bridger oppdaget", + "unknown": "Ukjent feil oppstod" + }, + "error": { + "linking": "Ukjent koblingsfeil oppstod.", + "register_failed": "Registrering feilet, vennligst pr\u00f8v igjen" + }, + "step": { + "init": { + "data": { + "host": "Vert" + }, + "title": "Velg Hue Bridge" + }, + "link": { + "description": "Trykk p\u00e5 knappen p\u00e5 Bridgen for \u00e5 registrere Philips Hue med Home Assistant. \n\n ![Knappens plassering p\u00e5 Bridgen](/static/images/config_philips_hue.jpg)", + "title": "Link Hub" + } + }, + "title": "Philips Hue Bridge" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/pl.json b/homeassistant/components/hue/.translations/pl.json new file mode 100644 index 00000000000..784fa0d99a6 --- /dev/null +++ b/homeassistant/components/hue/.translations/pl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "Wszystkie mostki Hue s\u0105 ju\u017c skonfigurowane", + "already_configured": "Mostek jest ju\u017c skonfigurowany", + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z mostkiem", + "discover_timeout": "Nie mo\u017cna wykry\u0107 \u017cadnych mostk\u00f3w Hue", + "no_bridges": "Nie wykryto \u017cadnych mostk\u00f3w Hue", + "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d" + }, + "error": { + "linking": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d w trakcie \u0142\u0105czenia.", + "register_failed": "Nie uda\u0142o si\u0119 zarejestrowa\u0107. Prosz\u0119 spr\u00f3bowa\u0107 ponownie." + }, + "step": { + "init": { + "data": { + "host": "Host" + }, + "title": "Wybierz mostek Hue" + }, + "link": { + "description": "Naci\u015bnij przycisk na mostku, aby zarejestrowa\u0107 Philips Hue z Home Assistant.", + "title": "Hub Link" + } + }, + "title": "Mostek Philips Hue" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/pt.json b/homeassistant/components/hue/.translations/pt.json new file mode 100644 index 00000000000..8c4c45f9c89 --- /dev/null +++ b/homeassistant/components/hue/.translations/pt.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/ro.json b/homeassistant/components/hue/.translations/ro.json new file mode 100644 index 00000000000..91541edcc7d --- /dev/null +++ b/homeassistant/components/hue/.translations/ro.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "linking": "A ap\u0103rut o eroare de leg\u0103tur\u0103 necunoscut\u0103.", + "register_failed": "Nu a reu\u0219it \u00eenregistrarea, \u00eencerca\u021bi din nou" + }, + "step": { + "init": { + "data": { + "host": "Gazd\u0103" + } + }, + "link": { + "description": "Ap\u0103sa\u021bi butonul de pe pod pentru a \u00eenregistra Philips Hue cu Home Assistant. \n\n ! [Loca\u021bia butonului pe pod] (/ static / images / config_philips_hue.jpg)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/ru.json b/homeassistant/components/hue/.translations/ru.json new file mode 100644 index 00000000000..ea1e4fff1bf --- /dev/null +++ b/homeassistant/components/hue/.translations/ru.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "\u0412\u0441\u0435 Philips Hue \u0448\u043b\u044e\u0437\u044b \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b", + "already_configured": "\u0428\u043b\u044e\u0437 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d", + "cannot_connect": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443", + "discover_timeout": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0442\u044c \u0448\u043b\u044e\u0437\u044b Philips Hue", + "no_bridges": "\u0428\u043b\u044e\u0437\u044b Philips Hue \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b", + "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" + }, + "error": { + "linking": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f", + "register_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430" + }, + "step": { + "init": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + }, + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0448\u043b\u044e\u0437 Hue" + }, + "link": { + "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0448\u043b\u044e\u0437\u0435 \u0434\u043b\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 Philips Hue \u0432 Home Assistant.\n\n![\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043d\u043e\u043f\u043a\u0438 \u043d\u0430 \u0448\u043b\u044e\u0437\u0435](/static/images/config_philips_hue.jpg)", + "title": "\u0421\u0432\u044f\u0437\u044c \u0441 \u0445\u0430\u0431\u043e\u043c" + } + }, + "title": "\u0428\u043b\u044e\u0437 Philips Hue" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/sl.json b/homeassistant/components/hue/.translations/sl.json new file mode 100644 index 00000000000..4245ce02c66 --- /dev/null +++ b/homeassistant/components/hue/.translations/sl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "Vsi mostovi Philips Hue so \u017ee konfigurirani", + "already_configured": "Most je \u017ee konfiguriran", + "cannot_connect": "Ni mogo\u010de vzpostaviti povezave z mostom", + "discover_timeout": "Ni bilo mogo\u010de odkriti Hue mostov", + "no_bridges": "Ni odkritih mostov Philips Hue", + "unknown": "Pri\u0161lo je do neznane napake" + }, + "error": { + "linking": "Pri\u0161lo je do neznane napake pri povezavi.", + "register_failed": "Registracija ni uspela, poskusite znova" + }, + "step": { + "init": { + "data": { + "host": "Host" + }, + "title": "Izberite Hue most" + }, + "link": { + "description": "Pritisnite gumb na mostu, da registrirate Philips Hue s Home Assistentom. \n\n ! [Polo\u017eaj gumba na mostu] (/static/images/config_philips_hue.jpg)", + "title": "Link Hub" + } + }, + "title": "Philips Hue Bridge" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/zh-Hans.json b/homeassistant/components/hue/.translations/zh-Hans.json new file mode 100644 index 00000000000..1d904070b81 --- /dev/null +++ b/homeassistant/components/hue/.translations/zh-Hans.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "\u5168\u90e8\u98de\u5229\u6d66 Hue \u6865\u63a5\u5668\u5df2\u914d\u7f6e", + "already_configured": "\u98de\u5229\u6d66 Hue Bridge \u5df2\u914d\u7f6e\u5b8c\u6210", + "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230 \u98de\u5229\u6d66 Hue Bridge", + "discover_timeout": "\u65e0\u6cd5\u55c5\u63a2 Hue \u6865\u63a5\u5668", + "no_bridges": "\u672a\u53d1\u73b0\u98de\u5229\u6d66 Hue Bridge", + "unknown": "\u51fa\u73b0\u672a\u77e5\u7684\u9519\u8bef" + }, + "error": { + "linking": "\u53d1\u751f\u672a\u77e5\u7684\u8fde\u63a5\u9519\u8bef\u3002", + "register_failed": "\u6ce8\u518c\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5" + }, + "step": { + "init": { + "data": { + "host": "\u4e3b\u673a" + }, + "title": "\u9009\u62e9 Hue Bridge" + }, + "link": { + "description": "\u8bf7\u6309\u4e0b\u6865\u63a5\u5668\u4e0a\u7684\u6309\u94ae\uff0c\u4ee5\u5728 Home Assistant \u4e0a\u6ce8\u518c\u98de\u5229\u6d66 Hue\u3002\n\n![\u6865\u63a5\u5668\u6309\u94ae\u4f4d\u7f6e](/static/images/config_philips_hue.jpg)", + "title": "\u8fde\u63a5\u4e2d\u67a2" + } + }, + "title": "\u98de\u5229\u6d66 Hue Bridge" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/zh-Hant.json b/homeassistant/components/hue/.translations/zh-Hant.json new file mode 100644 index 00000000000..eae4c09da49 --- /dev/null +++ b/homeassistant/components/hue/.translations/zh-Hant.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "\u6240\u6709 Philips Hue Bridge \u7686\u5df2\u8a2d\u5b9a\u5b8c\u6210", + "already_configured": "Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Bridge", + "discover_timeout": "\u7121\u6cd5\u641c\u5c0b\u5230 Hue Bridge", + "no_bridges": "\u672a\u641c\u5c0b\u5230 Philips Hue Bridge", + "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4" + }, + "error": { + "linking": "\u767c\u751f\u672a\u77e5\u9023\u7d50\u932f\u8aa4\u3002", + "register_failed": "\u8a3b\u518a\u5931\u6557\uff0c\u8acb\u7a0d\u5f8c\u518d\u8a66" + }, + "step": { + "init": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + }, + "title": "\u9078\u64c7 Hue Bridge" + }, + "link": { + "description": "\u6309\u4e0b Bridge \u4e0a\u7684\u6309\u9215\uff0c\u4ee5\u5c07 Philips Hue \u8a3b\u518a\u81f3 Home Assistant\u3002\n\n![Location of button on bridge](/static/images/config_philips_hue.jpg)", + "title": "\u9023\u7d50 Hub" + } + }, + "title": "Philips Hue Bridge" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index f15052fbd67..251d8cba095 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -4,31 +4,23 @@ This component provides basic support for the Philips Hue system. For more details about this component, please refer to the documentation at https://home-assistant.io/components/hue/ """ -import asyncio -import json -from functools import partial +import ipaddress import logging -import os -import socket -import async_timeout -import requests import voluptuous as vol -from homeassistant.components.discovery import SERVICE_HUE from homeassistant.const import CONF_FILENAME, CONF_HOST -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import discovery, aiohttp_client -from homeassistant import config_entries +from homeassistant.helpers import aiohttp_client, config_validation as cv -REQUIREMENTS = ['phue==1.0', 'aiohue==0.3.0'] +from .const import DOMAIN, API_NUPNP +from .bridge import HueBridge +# Loading the config flow file will register the flow +from .config_flow import configured_hosts + +REQUIREMENTS = ['aiohue==1.5.0'] _LOGGER = logging.getLogger(__name__) -DOMAIN = "hue" -SERVICE_HUE_SCENE = "hue_activate_scene" -API_NUPNP = 'https://www.meethue.com/api/nupnp' - CONF_BRIDGES = "bridges" CONF_ALLOW_UNREACHABLE = 'allow_unreachable' @@ -36,345 +28,112 @@ DEFAULT_ALLOW_UNREACHABLE = False PHUE_CONFIG_FILE = 'phue.conf' -CONF_ALLOW_IN_EMULATED_HUE = "allow_in_emulated_hue" -DEFAULT_ALLOW_IN_EMULATED_HUE = True - CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" DEFAULT_ALLOW_HUE_GROUPS = True -BRIDGE_CONFIG_SCHEMA = vol.Schema([{ - vol.Optional(CONF_HOST): cv.string, +BRIDGE_CONFIG_SCHEMA = vol.Schema({ + # Validate as IP address and then convert back to a string. + vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string), + # This is for legacy reasons and is only used for importing auth. vol.Optional(CONF_FILENAME, default=PHUE_CONFIG_FILE): cv.string, vol.Optional(CONF_ALLOW_UNREACHABLE, default=DEFAULT_ALLOW_UNREACHABLE): cv.boolean, - vol.Optional(CONF_ALLOW_IN_EMULATED_HUE, - default=DEFAULT_ALLOW_IN_EMULATED_HUE): cv.boolean, vol.Optional(CONF_ALLOW_HUE_GROUPS, default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, -}]) +}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_BRIDGES): BRIDGE_CONFIG_SCHEMA, + vol.Optional(CONF_BRIDGES): + vol.All(cv.ensure_list, [BRIDGE_CONFIG_SCHEMA]), }), }, extra=vol.ALLOW_EXTRA) -ATTR_GROUP_NAME = "group_name" -ATTR_SCENE_NAME = "scene_name" -SCENE_SCHEMA = vol.Schema({ - vol.Required(ATTR_GROUP_NAME): cv.string, - vol.Required(ATTR_SCENE_NAME): cv.string, -}) -CONFIG_INSTRUCTIONS = """ -Press the button on the bridge to register Philips Hue with Home Assistant. - -![Location of button on bridge](/static/images/config_philips_hue.jpg) -""" - - -def setup(hass, config): +async def async_setup(hass, config): """Set up the Hue platform.""" conf = config.get(DOMAIN) if conf is None: conf = {} - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - - discovery.listen( - hass, - SERVICE_HUE, - lambda service, discovery_info: - bridge_discovered(hass, service, discovery_info)) + hass.data[DOMAIN] = {} + configured = configured_hosts(hass) # User has configured bridges if CONF_BRIDGES in conf: bridges = conf[CONF_BRIDGES] + # Component is part of config but no bridges specified, discover. elif DOMAIN in config: # discover from nupnp - hosts = requests.get(API_NUPNP).json() - bridges = [{ - CONF_HOST: entry['internalipaddress'], - CONF_FILENAME: '.hue_{}.conf'.format(entry['id']), - } for entry in hosts] + websession = aiohttp_client.async_get_clientsession(hass) + + async with websession.get(API_NUPNP) as req: + hosts = await req.json() + + bridges = [] + for entry in hosts: + # Filter out already configured hosts + if entry['internalipaddress'] in configured: + continue + + # Run through config schema to populate defaults + bridges.append(BRIDGE_CONFIG_SCHEMA({ + CONF_HOST: entry['internalipaddress'], + # Careful with using entry['id'] for other reasons. The + # value is in lowercase but is returned uppercase from hub. + CONF_FILENAME: '.hue_{}.conf'.format(entry['id']), + })) else: # Component not specified in config, we're loaded via discovery bridges = [] - for bridge in bridges: - filename = bridge.get(CONF_FILENAME) - allow_unreachable = bridge.get(CONF_ALLOW_UNREACHABLE) - allow_in_emulated_hue = bridge.get(CONF_ALLOW_IN_EMULATED_HUE) - allow_hue_groups = bridge.get(CONF_ALLOW_HUE_GROUPS) + if not bridges: + return True - host = bridge.get(CONF_HOST) + for bridge_conf in bridges: + host = bridge_conf[CONF_HOST] - if host is None: - host = _find_host_from_config(hass, filename) + # Store config in hass.data so the config entry can find it + hass.data[DOMAIN][host] = bridge_conf - if host is None: - _LOGGER.error("No host found in configuration") - return False + # If configured, the bridge will be set up during config entry phase + if host in configured: + continue - setup_bridge(host, hass, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) + # No existing config entry found, try importing it or trigger link + # config flow if no existing auth. Because we're inside the setup of + # this component we'll have to use hass.async_add_job to avoid a + # deadlock: creating a config entry will set up the component but the + # setup would block till the entry is created! + hass.async_add_job(hass.config_entries.flow.async_init( + DOMAIN, source='import', data={ + 'host': bridge_conf[CONF_HOST], + 'path': bridge_conf[CONF_FILENAME], + } + )) return True -def bridge_discovered(hass, service, discovery_info): - """Dispatcher for Hue discovery events.""" - if "HASS Bridge" in discovery_info.get('name', ''): - return - - host = discovery_info.get('host') - serial = discovery_info.get('serial') - - filename = 'phue-{}.conf'.format(serial) - setup_bridge(host, hass, filename) - - -def setup_bridge(host, hass, filename=None, allow_unreachable=False, - allow_in_emulated_hue=True, allow_hue_groups=True, - username=None): - """Set up a given Hue bridge.""" - # Only register a device once - if socket.gethostbyname(host) in hass.data[DOMAIN]: - return - - bridge = HueBridge(host, hass, filename, username, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) - bridge.setup() - - -def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): - """Attempt to detect host based on existing configuration.""" - path = hass.config.path(filename) - - if not os.path.isfile(path): - return None - - try: - with open(path) as inp: - return next(iter(json.load(inp).keys())) - except (ValueError, AttributeError, StopIteration): - # ValueError if can't parse as JSON - # AttributeError if JSON value is not a dict - # StopIteration if no keys - return None - - -class HueBridge(object): - """Manages a single Hue bridge.""" - - def __init__(self, host, hass, filename, username, allow_unreachable=False, - allow_in_emulated_hue=True, allow_hue_groups=True): - """Initialize the system.""" - self.host = host - self.bridge_id = socket.gethostbyname(host) - self.hass = hass - self.filename = filename - self.username = username - self.allow_unreachable = allow_unreachable - self.allow_in_emulated_hue = allow_in_emulated_hue - self.allow_hue_groups = allow_hue_groups - - self.available = True - self.bridge = None - self.lights = {} - self.lightgroups = {} - - self.configured = False - self.config_request_id = None - - hass.data[DOMAIN][self.bridge_id] = self - - def setup(self): - """Set up a phue bridge based on host parameter.""" - import phue - - try: - kwargs = {} - if self.username is not None: - kwargs['username'] = self.username - if self.filename is not None: - kwargs['config_file_path'] = \ - self.hass.config.path(self.filename) - self.bridge = phue.Bridge(self.host, **kwargs) - except OSError: # Wrong host was given - _LOGGER.error("Error connecting to the Hue bridge at %s", - self.host) - return - except phue.PhueRegistrationException: - _LOGGER.warning("Connected to Hue at %s but not registered.", - self.host) - self.request_configuration() - return - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error connecting with Hue bridge at %s", - self.host) - return - - # If we came here and configuring this host, mark as done - if self.config_request_id: - request_id = self.config_request_id - self.config_request_id = None - configurator = self.hass.components.configurator - configurator.request_done(request_id) - - self.configured = True - - discovery.load_platform( - self.hass, 'light', DOMAIN, - {'bridge_id': self.bridge_id}) - - # create a service for calling run_scene directly on the bridge, - # used to simplify automation rules. - def hue_activate_scene(call): - """Service to call directly into bridge to set scenes.""" - group_name = call.data[ATTR_GROUP_NAME] - scene_name = call.data[ATTR_SCENE_NAME] - self.bridge.run_scene(group_name, scene_name) - - self.hass.services.register( - DOMAIN, SERVICE_HUE_SCENE, hue_activate_scene, - schema=SCENE_SCHEMA) - - def request_configuration(self): - """Request configuration steps from the user.""" - configurator = self.hass.components.configurator - - # We got an error if this method is called while we are configuring - if self.config_request_id: - configurator.notify_errors( - self.config_request_id, - "Failed to register, please try again.") - return - - self.config_request_id = configurator.request_config( - "Philips Hue", - lambda data: self.setup(), - description=CONFIG_INSTRUCTIONS, - entity_picture="/static/images/logo_philips_hue.png", - submit_caption="I have pressed the button" - ) - - def get_api(self): - """Return the full api dictionary from phue.""" - return self.bridge.get_api() - - def set_light(self, light_id, command): - """Adjust properties of one or more lights. See phue for details.""" - return self.bridge.set_light(light_id, command) - - def set_group(self, light_id, command): - """Change light settings for a group. See phue for detail.""" - return self.bridge.set_group(light_id, command) - - -@config_entries.HANDLERS.register(DOMAIN) -class HueFlowHandler(config_entries.ConfigFlowHandler): - """Handle a Hue config flow.""" - - VERSION = 1 - - def __init__(self): - """Initialize the Hue flow.""" - self.host = None - - @property - def _websession(self): - """Return a websession. - - Cannot assign in init because hass variable is not set yet. - """ - return aiohttp_client.async_get_clientsession(self.hass) - - async def async_step_init(self, user_input=None): - """Handle a flow start.""" - from aiohue.discovery import discover_nupnp - - if user_input is not None: - self.host = user_input['host'] - return await self.async_step_link() - - try: - with async_timeout.timeout(5): - bridges = await discover_nupnp(websession=self._websession) - except asyncio.TimeoutError: - return self.async_abort( - reason='discover_timeout' - ) - - if not bridges: - return self.async_abort( - reason='no_bridges' - ) - - # Find already configured hosts - configured_hosts = set( - entry.data['host'] for entry - in self.hass.config_entries.async_entries(DOMAIN)) - - hosts = [bridge.host for bridge in bridges - if bridge.host not in configured_hosts] - - if not hosts: - return self.async_abort( - reason='all_configured' - ) - - elif len(hosts) == 1: - self.host = hosts[0] - return await self.async_step_link() - - return self.async_show_form( - step_id='init', - data_schema=vol.Schema({ - vol.Required('host'): vol.In(hosts) - }) - ) - - async def async_step_link(self, user_input=None): - """Attempt to link with the Hue bridge.""" - import aiohue - errors = {} - - if user_input is not None: - bridge = aiohue.Bridge(self.host, websession=self._websession) - try: - with async_timeout.timeout(5): - # Create auth token - await bridge.create_user('home-assistant') - # Fetches name and id - await bridge.initialize() - except (asyncio.TimeoutError, aiohue.RequestError, - aiohue.LinkButtonNotPressed): - errors['base'] = 'register_failed' - except aiohue.AiohueException: - errors['base'] = 'linking' - _LOGGER.exception('Unknown Hue linking error occurred') - else: - return self.async_create_entry( - title=bridge.config.name, - data={ - 'host': bridge.host, - 'bridge_id': bridge.config.bridgeid, - 'username': bridge.username, - } - ) - - return self.async_show_form( - step_id='link', - errors=errors, - ) - - async def async_setup_entry(hass, entry): - """Set up a bridge for a config entry.""" - await hass.async_add_job(partial( - setup_bridge, entry.data['host'], hass, - username=entry.data['username'])) - return True + """Set up a bridge from a config entry.""" + host = entry.data['host'] + config = hass.data[DOMAIN].get(host) + + if config is None: + allow_unreachable = DEFAULT_ALLOW_UNREACHABLE + allow_groups = DEFAULT_ALLOW_HUE_GROUPS + else: + allow_unreachable = config[CONF_ALLOW_UNREACHABLE] + allow_groups = config[CONF_ALLOW_HUE_GROUPS] + + bridge = HueBridge(hass, entry, allow_unreachable, allow_groups) + hass.data[DOMAIN][host] = bridge + return await bridge.async_setup() + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + bridge = hass.data[DOMAIN].pop(entry.data['host']) + return await bridge.async_reset() diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py new file mode 100644 index 00000000000..d7a8dc7f730 --- /dev/null +++ b/homeassistant/components/hue/bridge.py @@ -0,0 +1,187 @@ +"""Code to handle a Hue bridge.""" +import asyncio + +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from .const import DOMAIN, LOGGER +from .errors import AuthenticationRequired, CannotConnect + +SERVICE_HUE_SCENE = "hue_activate_scene" +ATTR_GROUP_NAME = "group_name" +ATTR_SCENE_NAME = "scene_name" +SCENE_SCHEMA = vol.Schema({ + vol.Required(ATTR_GROUP_NAME): cv.string, + vol.Required(ATTR_SCENE_NAME): cv.string, +}) + + +class HueBridge(object): + """Manages a single Hue bridge.""" + + def __init__(self, hass, config_entry, allow_unreachable, allow_groups): + """Initialize the system.""" + self.config_entry = config_entry + self.hass = hass + self.allow_unreachable = allow_unreachable + self.allow_groups = allow_groups + self.available = True + self.api = None + self._cancel_retry_setup = None + + @property + def host(self): + """Return the host of this bridge.""" + return self.config_entry.data['host'] + + async def async_setup(self, tries=0): + """Set up a phue bridge based on host parameter.""" + host = self.host + hass = self.hass + + try: + self.api = await get_bridge( + hass, host, self.config_entry.data['username']) + except AuthenticationRequired: + # usernames can become invalid if hub is reset or user removed. + # We are going to fail the config entry setup and initiate a new + # linking procedure. When linking succeeds, it will remove the + # old config entry. + hass.async_add_job(hass.config_entries.flow.async_init( + DOMAIN, source='import', data={ + 'host': host, + } + )) + return False + + except CannotConnect: + retry_delay = 2 ** (tries + 1) + LOGGER.error("Error connecting to the Hue bridge at %s. Retrying " + "in %d seconds", host, retry_delay) + + async def retry_setup(_now): + """Retry setup.""" + if await self.async_setup(tries + 1): + # This feels hacky, we should find a better way to do this + self.config_entry.state = config_entries.ENTRY_STATE_LOADED + + self._cancel_retry_setup = hass.helpers.event.async_call_later( + retry_delay, retry_setup) + + return False + + except Exception: # pylint: disable=broad-except + LOGGER.exception('Unknown error connecting with Hue bridge at %s', + host) + return False + + hass.async_add_job(hass.config_entries.async_forward_entry_setup( + self.config_entry, 'light')) + + hass.services.async_register( + DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene, + schema=SCENE_SCHEMA) + + return True + + async def async_reset(self): + """Reset this bridge to default state. + + Will cancel any scheduled setup retry and will unload + the config entry. + """ + # The bridge can be in 3 states: + # - Setup was successful, self.api is not None + # - Authentication was wrong, self.api is None, not retrying setup. + # - Host was down. self.api is None, we're retrying setup + + # If we have a retry scheduled, we were never setup. + if self._cancel_retry_setup is not None: + self._cancel_retry_setup() + self._cancel_retry_setup = None + return True + + # If the authentication was wrong. + if self.api is None: + return True + + self.hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE) + + # If setup was successful, we set api variable, forwarded entry and + # register service + return await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'light') + + async def hue_activate_scene(self, call, updated=False): + """Service to call directly into bridge to set scenes.""" + group_name = call.data[ATTR_GROUP_NAME] + scene_name = call.data[ATTR_SCENE_NAME] + + group = next( + (group for group in self.api.groups.values() + if group.name == group_name), None) + + # The same scene name can exist in multiple groups. + # In this case, activate first scene that contains the + # the exact same light IDs as the group + scenes = [] + for scene in self.api.scenes.values(): + if scene.name == scene_name: + scenes.append(scene) + if len(scenes) == 1: + scene_id = scenes[0].id + else: + group_lights = sorted(group.lights) + for scene in scenes: + if group_lights == scene.lights: + scene_id = scene.id + break + + # If we can't find it, fetch latest info. + if not updated and (group is None or scene_id is None): + await self.api.groups.update() + await self.api.scenes.update() + await self.hue_activate_scene(call, updated=True) + return + + if group is None: + LOGGER.warning('Unable to find group %s', group_name) + return + + if scene_id is None: + LOGGER.warning('Unable to find scene %s', scene_name) + return + + await group.set_action(scene=scene_id) + + +async def get_bridge(hass, host, username=None): + """Create a bridge object and verify authentication.""" + import aiohue + + bridge = aiohue.Bridge( + host, username=username, + websession=aiohttp_client.async_get_clientsession(hass) + ) + + try: + with async_timeout.timeout(5): + # Create username if we don't have one + if not username: + await bridge.create_user('home-assistant') + # Initialize bridge (and validate our username) + await bridge.initialize() + + return bridge + except (aiohue.LinkButtonNotPressed, aiohue.Unauthorized): + LOGGER.warning("Connected to Hue at %s but not registered.", host) + raise AuthenticationRequired + except (asyncio.TimeoutError, aiohue.RequestError): + LOGGER.error("Error connecting to the Hue bridge at %s", host) + raise CannotConnect + except aiohue.AiohueException: + LOGGER.exception('Unknown Hue linking error occurred') + raise AuthenticationRequired diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py new file mode 100644 index 00000000000..af67a594495 --- /dev/null +++ b/homeassistant/components/hue/config_flow.py @@ -0,0 +1,235 @@ +"""Config flow to configure Philips Hue.""" +import asyncio +import json +import os + +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client + +from .bridge import get_bridge +from .const import DOMAIN, LOGGER +from .errors import AuthenticationRequired, CannotConnect + + +@callback +def configured_hosts(hass): + """Return a set of the configured hosts.""" + return set(entry.data['host'] for entry + in hass.config_entries.async_entries(DOMAIN)) + + +def _find_username_from_config(hass, filename): + """Load username from config. + + This was a legacy way of configuring Hue until Home Assistant 0.67. + """ + path = hass.config.path(filename) + + if not os.path.isfile(path): + return None + + with open(path) as inp: + try: + return list(json.load(inp).values())[0]['username'] + except ValueError: + # If we get invalid JSON + return None + + +@config_entries.HANDLERS.register(DOMAIN) +class HueFlowHandler(data_entry_flow.FlowHandler): + """Handle a Hue config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize the Hue flow.""" + self.host = None + + async def async_step_init(self, user_input=None): + """Handle a flow start.""" + from aiohue.discovery import discover_nupnp + + if user_input is not None: + self.host = user_input['host'] + return await self.async_step_link() + + websession = aiohttp_client.async_get_clientsession(self.hass) + + try: + with async_timeout.timeout(5): + bridges = await discover_nupnp(websession=websession) + except asyncio.TimeoutError: + return self.async_abort( + reason='discover_timeout' + ) + + if not bridges: + return self.async_abort( + reason='no_bridges' + ) + + # Find already configured hosts + configured = configured_hosts(self.hass) + + hosts = [bridge.host for bridge in bridges + if bridge.host not in configured] + + if not hosts: + return self.async_abort( + reason='all_configured' + ) + + elif len(hosts) == 1: + self.host = hosts[0] + return await self.async_step_link() + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({ + vol.Required('host'): vol.In(hosts) + }) + ) + + async def async_step_link(self, user_input=None): + """Attempt to link with the Hue bridge. + + Given a configured host, will ask the user to press the link button + to connect to the bridge. + """ + errors = {} + + # We will always try linking in case the user has already pressed + # the link button. + try: + bridge = await get_bridge( + self.hass, self.host, username=None + ) + + return await self._entry_from_bridge(bridge) + except AuthenticationRequired: + errors['base'] = 'register_failed' + + except CannotConnect: + LOGGER.error("Error connecting to the Hue bridge at %s", self.host) + errors['base'] = 'linking' + + except Exception: # pylint: disable=broad-except + LOGGER.exception( + 'Unknown error connecting with Hue bridge at %s', + self.host) + errors['base'] = 'linking' + + # If there was no user input, do not show the errors. + if user_input is None: + errors = {} + + return self.async_show_form( + step_id='link', + errors=errors, + ) + + async def async_step_discovery(self, discovery_info): + """Handle a discovered Hue bridge. + + This flow is triggered by the discovery component. It will check if the + host is already configured and delegate to the import step if not. + """ + # Filter out emulated Hue + if "HASS Bridge" in discovery_info.get('name', ''): + return self.async_abort(reason='already_configured') + + host = discovery_info.get('host') + + if host in configured_hosts(self.hass): + return self.async_abort(reason='already_configured') + + # This value is based off host/description.xml and is, weirdly, missing + # 4 characters in the middle of the serial compared to results returned + # from the NUPNP API or when querying the bridge API for bridgeid. + # (on first gen Hue hub) + serial = discovery_info.get('serial') + + return await self.async_step_import({ + 'host': host, + # This format is the legacy format that Hue used for discovery + 'path': 'phue-{}.conf'.format(serial) + }) + + async def async_step_import(self, import_info): + """Import a new bridge as a config entry. + + Will read authentication from Phue config file if available. + + This flow is triggered by `async_setup` for both configured and + discovered bridges. Triggered for any bridge that does not have a + config entry yet (based on host). + + This flow is also triggered by `async_step_discovery`. + + If an existing config file is found, we will validate the credentials + and create an entry. Otherwise we will delegate to `link` step which + will ask user to link the bridge. + """ + host = import_info['host'] + path = import_info.get('path') + + if path is not None: + username = await self.hass.async_add_job( + _find_username_from_config, self.hass, + self.hass.config.path(path)) + else: + username = None + + try: + bridge = await get_bridge( + self.hass, host, username + ) + + LOGGER.info('Imported authentication for %s from %s', host, path) + + return await self._entry_from_bridge(bridge) + except AuthenticationRequired: + self.host = host + + LOGGER.info('Invalid authentication for %s, requesting link.', + host) + + return await self.async_step_link() + + except CannotConnect: + LOGGER.error("Error connecting to the Hue bridge at %s", host) + return self.async_abort(reason='cannot_connect') + + except Exception: # pylint: disable=broad-except + LOGGER.exception('Unknown error connecting with Hue bridge at %s', + host) + return self.async_abort(reason='unknown') + + async def _entry_from_bridge(self, bridge): + """Return a config entry from an initialized bridge.""" + # Remove all other entries of hubs with same ID or host + host = bridge.host + bridge_id = bridge.config.bridgeid + + same_hub_entries = [entry.entry_id for entry + in self.hass.config_entries.async_entries(DOMAIN) + if entry.data['bridge_id'] == bridge_id or + entry.data['host'] == host] + + if same_hub_entries: + await asyncio.wait([self.hass.config_entries.async_remove(entry_id) + for entry_id in same_hub_entries]) + + return self.async_create_entry( + title=bridge.config.name, + data={ + 'host': host, + 'bridge_id': bridge_id, + 'username': bridge.username, + } + ) diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py new file mode 100644 index 00000000000..2eb30d47804 --- /dev/null +++ b/homeassistant/components/hue/const.py @@ -0,0 +1,6 @@ +"""Constants for the Hue component.""" +import logging + +LOGGER = logging.getLogger('homeassistant.components.hue') +DOMAIN = "hue" +API_NUPNP = 'https://www.meethue.com/api/nupnp' diff --git a/homeassistant/components/hue/errors.py b/homeassistant/components/hue/errors.py new file mode 100644 index 00000000000..dd217c3bc26 --- /dev/null +++ b/homeassistant/components/hue/errors.py @@ -0,0 +1,14 @@ +"""Errors for the Hue component.""" +from homeassistant.exceptions import HomeAssistantError + + +class HueException(HomeAssistantError): + """Base class for Hue exceptions.""" + + +class CannotConnect(HueException): + """Unable to connect to the bridge.""" + + +class AuthenticationRequired(HueException): + """Unknown error occurred.""" diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 59b1ecd3cd1..fc9e91c93d7 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -20,7 +20,10 @@ "abort": { "discover_timeout": "Unable to discover Hue bridges", "no_bridges": "No Philips Hue bridges discovered", - "all_configured": "All Philips Hue bridges are already configured" + "all_configured": "All Philips Hue bridges are already configured", + "unknown": "Unknown error occurred", + "cannot_connect": "Unable to connect to the bridge", + "already_configured": "Bridge is already configured" } } } diff --git a/homeassistant/components/hydrawise.py b/homeassistant/components/hydrawise.py new file mode 100644 index 00000000000..a60e3d5b8fc --- /dev/null +++ b/homeassistant/components/hydrawise.py @@ -0,0 +1,153 @@ +""" +Support for Hydrawise cloud. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hydrawise/ +""" +import asyncio +from datetime import timedelta +import logging + +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL) +import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['hydrawiser==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] + +CONF_ATTRIBUTION = "Data provided by hydrawise.com" +CONF_WATERING_TIME = 'watering_minutes' + +NOTIFICATION_ID = 'hydrawise_notification' +NOTIFICATION_TITLE = 'Hydrawise Setup' + +DATA_HYDRAWISE = 'hydrawise' +DOMAIN = 'hydrawise' +DEFAULT_WATERING_TIME = 15 + +DEVICE_MAP_INDEX = ['KEY_INDEX', 'ICON_INDEX', 'DEVICE_CLASS_INDEX', + 'UNIT_OF_MEASURE_INDEX'] +DEVICE_MAP = { + 'auto_watering': ['Automatic Watering', 'mdi:autorenew', '', ''], + 'is_watering': ['Watering', '', 'moisture', ''], + 'manual_watering': ['Manual Watering', 'mdi:water-pump', '', ''], + 'next_cycle': ['Next Cycle', 'mdi:calendar-clock', '', ''], + 'status': ['Status', '', 'connectivity', ''], + 'watering_time': ['Watering Time', 'mdi:water-pump', '', 'min'], + 'rain_sensor': ['Rain Sensor', '', 'moisture', ''] +} + +BINARY_SENSORS = ['is_watering', 'status', 'rain_sensor'] + +SENSORS = ['next_cycle', 'watering_time'] + +SWITCHES = ['auto_watering', 'manual_watering'] + +SCAN_INTERVAL = timedelta(seconds=30) + +SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Hunter Hydrawise component.""" + conf = config[DOMAIN] + access_token = conf[CONF_ACCESS_TOKEN] + scan_interval = conf.get(CONF_SCAN_INTERVAL) + + try: + from hydrawiser.core import Hydrawiser + + hydrawise = Hydrawiser(user_token=access_token) + hass.data[DATA_HYDRAWISE] = HydrawiseHub(hydrawise) + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error( + "Unable to connect to Hydrawise cloud service: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + def hub_refresh(event_time): + """Call Hydrawise hub to refresh information.""" + _LOGGER.debug("Updating Hydrawise Hub component") + hass.data[DATA_HYDRAWISE].data.update_controller_info() + dispatcher_send(hass, SIGNAL_UPDATE_HYDRAWISE) + + # Call the Hydrawise API to refresh updates + track_time_interval(hass, hub_refresh, scan_interval) + + return True + + +class HydrawiseHub(object): + """Representation of a base Hydrawise device.""" + + def __init__(self, data): + """Initialize the entity.""" + self.data = data + + +class HydrawiseEntity(Entity): + """Entity class for Hydrawise devices.""" + + def __init__(self, data, sensor_type): + """Initialize the Hydrawise entity.""" + self.data = data + self._sensor_type = sensor_type + self._name = "{0} {1}".format( + self.data['name'], + DEVICE_MAP[self._sensor_type][ + DEVICE_MAP_INDEX.index('KEY_INDEX')]) + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_HYDRAWISE, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return DEVICE_MAP[self._sensor_type][ + DEVICE_MAP_INDEX.index('UNIT_OF_MEASURE_INDEX')] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'identifier': self.data.get('relay'), + } diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index 031fa263e5a..0c0100bc9f5 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -1,4 +1,5 @@ -"""IHC component. +""" +Support for IHC devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/ihc/ @@ -6,18 +7,18 @@ https://home-assistant.io/components/ihc/ import logging import os.path import xml.etree.ElementTree + import voluptuous as vol from homeassistant.components.ihc.const import ( - ATTR_IHC_ID, ATTR_VALUE, CONF_INFO, CONF_AUTOSETUP, - CONF_BINARY_SENSOR, CONF_LIGHT, CONF_SENSOR, CONF_SWITCH, - CONF_XPATH, CONF_NODE, CONF_DIMMABLE, CONF_INVERTING, - SERVICE_SET_RUNTIME_VALUE_BOOL, SERVICE_SET_RUNTIME_VALUE_INT, - SERVICE_SET_RUNTIME_VALUE_FLOAT) + ATTR_IHC_ID, ATTR_VALUE, CONF_AUTOSETUP, CONF_BINARY_SENSOR, CONF_DIMMABLE, + CONF_INFO, CONF_INVERTING, CONF_LIGHT, CONF_NODE, CONF_SENSOR, CONF_SWITCH, + CONF_XPATH, SERVICE_SET_RUNTIME_VALUE_BOOL, + SERVICE_SET_RUNTIME_VALUE_FLOAT, SERVICE_SET_RUNTIME_VALUE_INT) from homeassistant.config import load_yaml_config_file from homeassistant.const import ( - CONF_URL, CONF_USERNAME, CONF_PASSWORD, CONF_ID, CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, CONF_TYPE, TEMP_CELSIUS) + CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, + CONF_URL, CONF_USERNAME, TEMP_CELSIUS) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType @@ -36,7 +37,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_AUTOSETUP, default=True): cv.boolean, - vol.Optional(CONF_INFO, default=True): cv.boolean + vol.Optional(CONF_INFO, default=True): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) @@ -97,7 +98,7 @@ IHC_PLATFORMS = ('binary_sensor', 'light', 'sensor', 'switch') def setup(hass, config): - """Setup the IHC component.""" + """Set up the IHC component.""" from ihcsdk.ihccontroller import IHCController conf = config[DOMAIN] url = conf[CONF_URL] @@ -106,7 +107,7 @@ def setup(hass, config): ihc_controller = IHCController(url, username, password) if not ihc_controller.authenticate(): - _LOGGER.error("Unable to authenticate on ihc controller.") + _LOGGER.error("Unable to authenticate on IHC controller") return False if (conf[CONF_AUTOSETUP] and @@ -125,7 +126,7 @@ def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller): """Auto setup of IHC products from the ihc project file.""" project_xml = ihc_controller.get_project() if not project_xml: - _LOGGER.error("Unable to read project from ihc controller.") + _LOGGER.error("Unable to read project from ICH controller") return False project = xml.etree.ElementTree.fromstring(project_xml) @@ -150,7 +151,7 @@ def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller): def get_discovery_info(component_setup, groups): - """Get discovery info for specified component.""" + """Get discovery info for specified IHC component.""" discovery_data = {} for group in groups: groupname = group.attrib['name'] @@ -173,7 +174,7 @@ def get_discovery_info(component_setup, groups): def setup_service_functions(hass: HomeAssistantType, ihc_controller): - """Setup the ihc service functions.""" + """Setup the IHC service functions.""" def set_runtime_value_bool(call): """Set a IHC runtime bool value service function.""" ihc_id = call.data[ATTR_IHC_ID] diff --git a/homeassistant/components/ihc/ihcdevice.py b/homeassistant/components/ihc/ihcdevice.py index 59f4d95f0a1..de6db875def 100644 --- a/homeassistant/components/ihc/ihcdevice.py +++ b/homeassistant/components/ihc/ihcdevice.py @@ -1,4 +1,4 @@ -"""Implements a base class for all IHC devices.""" +"""Implementation of a base class for all IHC devices.""" import asyncio from xml.etree.ElementTree import Element @@ -6,7 +6,7 @@ from homeassistant.helpers.entity import Entity class IHCDevice(Entity): - """Base class for all ihc devices. + """Base class for all IHC devices. All IHC devices have an associated IHC resource. IHCDevice handled the registration of the IHC controller callback when the IHC resource changes. @@ -31,13 +31,13 @@ class IHCDevice(Entity): @asyncio.coroutine def async_added_to_hass(self): - """Add callback for ihc changes.""" + """Add callback for IHC changes.""" self.ihc_controller.add_notify_event( self.ihc_id, self.on_ihc_change, True) @property def should_poll(self) -> bool: - """No polling needed for ihc devices.""" + """No polling needed for IHC devices.""" return False @property @@ -58,7 +58,7 @@ class IHCDevice(Entity): } def on_ihc_change(self, ihc_id, value): - """Callback when ihc resource changes. + """Callback when IHC resource changes. Derived classes must overwrite this to do device specific stuff. """ diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 061fd5d7074..29f26cc84e6 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -10,14 +10,15 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_NAME, CONF_ENTITY_ID) + ATTR_ENTITY_ID, ATTR_NAME, CONF_ENTITY_ID, CONF_NAME) +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.loader import get_component +from homeassistant.loader import bind_hass +from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -34,7 +35,15 @@ DEVICE_CLASSES = [ SERVICE_SCAN = 'scan' +EVENT_DETECT_FACE = 'image_processing.detect_face' + +ATTR_AGE = 'age' ATTR_CONFIDENCE = 'confidence' +ATTR_FACES = 'faces' +ATTR_GENDER = 'gender' +ATTR_GLASSES = 'glasses' +ATTR_MOTION = 'motion' +ATTR_TOTAL_FACES = 'total_faces' CONF_SOURCE = 'source' CONF_CONFIDENCE = 'confidence' @@ -50,7 +59,7 @@ SOURCE_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SOURCE): vol.All(cv.ensure_list, [SOURCE_SCHEMA]), vol.Optional(CONF_CONFIDENCE, default=DEFAULT_CONFIDENCE): - vol.All(vol.Coerce(float), vol.Range(min=0, max=100)) + vol.All(vol.Coerce(float), vol.Range(min=0, max=100)), }) SERVICE_SCAN_SCHEMA = vol.Schema({ @@ -67,7 +76,7 @@ def scan(hass, entity_id=None): @asyncio.coroutine def async_setup(hass, config): - """Set up image processing.""" + """Set up the image processing.""" component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) yield from component.async_setup(config) @@ -121,16 +130,103 @@ class ImageProcessingEntity(Entity): This method is a coroutine. """ - camera = get_component('camera') + camera = self.hass.components.camera image = None try: image = yield from camera.async_get_image( - self.hass, self.camera_entity, timeout=self.timeout) + self.camera_entity, timeout=self.timeout) except HomeAssistantError as err: _LOGGER.error("Error on receive image from entity: %s", err) return # process image data - yield from self.async_process_image(image) + yield from self.async_process_image(image.content) + + +class ImageProcessingFaceEntity(ImageProcessingEntity): + """Base entity class for face image processing.""" + + def __init__(self): + """Initialize base face identify/verify entity.""" + self.faces = [] + self.total_faces = 0 + + @property + def state(self): + """Return the state of the entity.""" + confidence = 0 + state = None + + # No confidence support + if not self.confidence: + return self.total_faces + + # Search high confidence + for face in self.faces: + if ATTR_CONFIDENCE not in face: + continue + + f_co = face[ATTR_CONFIDENCE] + if f_co > confidence: + confidence = f_co + for attr in [ATTR_NAME, ATTR_MOTION]: + if attr in face: + state = face[attr] + break + + return state + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'face' + + @property + def state_attributes(self): + """Return device specific state attributes.""" + attr = { + ATTR_FACES: self.faces, + ATTR_TOTAL_FACES: self.total_faces, + } + + return attr + + def process_faces(self, faces, total): + """Send event with detected faces and store data.""" + run_callback_threadsafe( + self.hass.loop, self.async_process_faces, faces, total).result() + + @callback + def async_process_faces(self, faces, total): + """Send event with detected faces and store data. + + known are a dict in follow format: + [ + { + ATTR_CONFIDENCE: 80, + ATTR_NAME: 'Name', + ATTR_AGE: 12.0, + ATTR_GENDER: 'man', + ATTR_MOTION: 'smile', + ATTR_GLASSES: 'sunglasses' + }, + ] + + This method must be run in the event loop. + """ + # Send events + for face in faces: + if ATTR_CONFIDENCE in face and self.confidence: + if face[ATTR_CONFIDENCE] < self.confidence: + continue + + face.update({ATTR_ENTITY_ID: self.entity_id}) + self.hass.async_add_job( + self.hass.bus.async_fire, EVENT_DETECT_FACE, face + ) + + # Update entity store + self.faces = faces + self.total_faces = total diff --git a/homeassistant/components/image_processing/demo.py b/homeassistant/components/image_processing/demo.py index 788d12520f5..e225113b5b1 100644 --- a/homeassistant/components/image_processing/demo.py +++ b/homeassistant/components/image_processing/demo.py @@ -4,11 +4,12 @@ Support for the demo image processing. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/demo/ """ -from homeassistant.components.image_processing import ATTR_CONFIDENCE +from homeassistant.components.image_processing import ( + ImageProcessingFaceEntity, ATTR_CONFIDENCE, ATTR_NAME, ATTR_AGE, + ATTR_GENDER + ) from homeassistant.components.image_processing.openalpr_local import ( ImageProcessingAlprEntity) -from homeassistant.components.image_processing.microsoft_face_identify import ( - ImageProcessingFaceEntity, ATTR_NAME, ATTR_AGE, ATTR_GENDER) def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/image_processing/dlib_face_detect.py b/homeassistant/components/image_processing/dlib_face_detect.py index 65705feb7f7..d4a20da253c 100644 --- a/homeassistant/components/image_processing/dlib_face_detect.py +++ b/homeassistant/components/image_processing/dlib_face_detect.py @@ -11,9 +11,7 @@ from homeassistant.core import split_entity_id # pylint: disable=unused-import from homeassistant.components.image_processing import PLATFORM_SCHEMA # noqa from homeassistant.components.image_processing import ( - CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME) -from homeassistant.components.image_processing.microsoft_face_identify import ( - ImageProcessingFaceEntity) + ImageProcessingFaceEntity, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME) REQUIREMENTS = ['face_recognition==1.0.0'] diff --git a/homeassistant/components/image_processing/dlib_face_identify.py b/homeassistant/components/image_processing/dlib_face_identify.py index 22594aa2547..bf34eb4c2da 100644 --- a/homeassistant/components/image_processing/dlib_face_identify.py +++ b/homeassistant/components/image_processing/dlib_face_identify.py @@ -11,9 +11,8 @@ import voluptuous as vol from homeassistant.core import split_entity_id from homeassistant.components.image_processing import ( - PLATFORM_SCHEMA, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME) -from homeassistant.components.image_processing.microsoft_face_identify import ( - ImageProcessingFaceEntity) + ImageProcessingFaceEntity, PLATFORM_SCHEMA, CONF_SOURCE, CONF_ENTITY_ID, + CONF_NAME) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['face_recognition==1.0.0'] diff --git a/homeassistant/components/image_processing/facebox.py b/homeassistant/components/image_processing/facebox.py new file mode 100644 index 00000000000..81b43c1f8e0 --- /dev/null +++ b/homeassistant/components/image_processing/facebox.py @@ -0,0 +1,110 @@ +""" +Component that will perform facial detection and identification via facebox. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/image_processing.facebox +""" +import base64 +import logging + +import requests +import voluptuous as vol + +from homeassistant.core import split_entity_id +import homeassistant.helpers.config_validation as cv +from homeassistant.components.image_processing import ( + PLATFORM_SCHEMA, ImageProcessingFaceEntity, CONF_SOURCE, CONF_ENTITY_ID, + CONF_NAME) +from homeassistant.const import (CONF_IP_ADDRESS, CONF_PORT) + +_LOGGER = logging.getLogger(__name__) + +CLASSIFIER = 'facebox' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PORT): cv.port, +}) + + +def encode_image(image): + """base64 encode an image stream.""" + base64_img = base64.b64encode(image).decode('ascii') + return {"base64": base64_img} + + +def get_matched_faces(faces): + """Return the name and rounded confidence of matched faces.""" + return {face['name']: round(face['confidence'], 2) + for face in faces if face['matched']} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the classifier.""" + entities = [] + for camera in config[CONF_SOURCE]: + entities.append(FaceClassifyEntity( + config[CONF_IP_ADDRESS], + config[CONF_PORT], + camera[CONF_ENTITY_ID], + camera.get(CONF_NAME) + )) + add_devices(entities) + + +class FaceClassifyEntity(ImageProcessingFaceEntity): + """Perform a face classification.""" + + def __init__(self, ip, port, camera_entity, name=None): + """Init with the API key and model id.""" + super().__init__() + self._url = "http://{}:{}/{}/check".format(ip, port, CLASSIFIER) + self._camera = camera_entity + if name: + self._name = name + else: + camera_name = split_entity_id(camera_entity)[1] + self._name = "{} {}".format( + CLASSIFIER, camera_name) + self._matched = {} + + def process_image(self, image): + """Process an image.""" + response = {} + try: + response = requests.post( + self._url, + json=encode_image(image), + timeout=9 + ).json() + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) + response['success'] = False + + if response['success']: + faces = response['faces'] + total = response['facesCount'] + self.process_faces(faces, total) + self._matched = get_matched_faces(faces) + + else: + self.total_faces = None + self.faces = [] + self._matched = {} + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_state_attributes(self): + """Return the classifier attributes.""" + return { + 'matched_faces': self._matched, + } diff --git a/homeassistant/components/image_processing/microsoft_face_detect.py b/homeassistant/components/image_processing/microsoft_face_detect.py index 6770ff1bdf6..bda0e1bc550 100644 --- a/homeassistant/components/image_processing/microsoft_face_detect.py +++ b/homeassistant/components/image_processing/microsoft_face_detect.py @@ -9,13 +9,12 @@ import logging import voluptuous as vol +from homeassistant.components.image_processing import ( + ATTR_AGE, ATTR_GENDER, ATTR_GLASSES, CONF_ENTITY_ID, CONF_NAME, + CONF_SOURCE, PLATFORM_SCHEMA, ImageProcessingFaceEntity) +from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE from homeassistant.core import split_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE -from homeassistant.components.image_processing import ( - PLATFORM_SCHEMA, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME) -from homeassistant.components.image_processing.microsoft_face_identify import ( - ImageProcessingFaceEntity, ATTR_GENDER, ATTR_AGE, ATTR_GLASSES) import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['microsoft_face'] diff --git a/homeassistant/components/image_processing/microsoft_face_identify.py b/homeassistant/components/image_processing/microsoft_face_identify.py index 51f1cd42f47..8984f25cdf2 100644 --- a/homeassistant/components/image_processing/microsoft_face_identify.py +++ b/homeassistant/components/image_processing/microsoft_face_identify.py @@ -9,30 +9,19 @@ import logging import voluptuous as vol -from homeassistant.core import split_entity_id, callback -from homeassistant.const import STATE_UNKNOWN -from homeassistant.exceptions import HomeAssistantError -from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE from homeassistant.components.image_processing import ( - PLATFORM_SCHEMA, ImageProcessingEntity, CONF_CONFIDENCE, CONF_SOURCE, - CONF_ENTITY_ID, CONF_NAME, ATTR_ENTITY_ID, ATTR_CONFIDENCE) + ATTR_CONFIDENCE, CONF_CONFIDENCE, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE, + PLATFORM_SCHEMA, ImageProcessingFaceEntity) +from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE +from homeassistant.const import ATTR_NAME +from homeassistant.core import split_entity_id +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from homeassistant.util.async_ import run_callback_threadsafe DEPENDENCIES = ['microsoft_face'] _LOGGER = logging.getLogger(__name__) -EVENT_DETECT_FACE = 'image_processing.detect_face' - -ATTR_NAME = 'name' -ATTR_TOTAL_FACES = 'total_faces' -ATTR_AGE = 'age' -ATTR_GENDER = 'gender' -ATTR_MOTION = 'motion' -ATTR_GLASSES = 'glasses' -ATTR_FACES = 'faces' - CONF_GROUP = 'group' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -57,93 +46,6 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): async_add_devices(entities) -class ImageProcessingFaceEntity(ImageProcessingEntity): - """Base entity class for face image processing.""" - - def __init__(self): - """Initialize base face identify/verify entity.""" - self.faces = [] - self.total_faces = 0 - - @property - def state(self): - """Return the state of the entity.""" - confidence = 0 - state = STATE_UNKNOWN - - # No confidence support - if not self.confidence: - return self.total_faces - - # Search high confidence - for face in self.faces: - if ATTR_CONFIDENCE not in face: - continue - - f_co = face[ATTR_CONFIDENCE] - if f_co > confidence: - confidence = f_co - for attr in [ATTR_NAME, ATTR_MOTION]: - if attr in face: - state = face[attr] - break - - return state - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return 'face' - - @property - def state_attributes(self): - """Return device specific state attributes.""" - attr = { - ATTR_FACES: self.faces, - ATTR_TOTAL_FACES: self.total_faces, - } - - return attr - - def process_faces(self, faces, total): - """Send event with detected faces and store data.""" - run_callback_threadsafe( - self.hass.loop, self.async_process_faces, faces, total).result() - - @callback - def async_process_faces(self, faces, total): - """Send event with detected faces and store data. - - known are a dict in follow format: - [ - { - ATTR_CONFIDENCE: 80, - ATTR_NAME: 'Name', - ATTR_AGE: 12.0, - ATTR_GENDER: 'man', - ATTR_MOTION: 'smile', - ATTR_GLASSES: 'sunglasses' - }, - ] - - This method must be run in the event loop. - """ - # Send events - for face in faces: - if ATTR_CONFIDENCE in face and self.confidence: - if face[ATTR_CONFIDENCE] < self.confidence: - continue - - face.update({ATTR_ENTITY_ID: self.entity_id}) - self.hass.async_add_job( - self.hass.bus.async_fire, EVENT_DETECT_FACE, face - ) - - # Update entity store - self.faces = faces - self.total_faces = total - - class MicrosoftFaceIdentifyEntity(ImageProcessingFaceEntity): """Representation of the Microsoft Face API entity for identify.""" diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index df58e2e9dc4..c3e34b4d42b 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -16,7 +16,7 @@ from homeassistant.components.image_processing import ( from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.14.0'] +REQUIREMENTS = ['numpy==1.14.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 1f7f9f6262f..6d54324542a 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -9,6 +9,7 @@ import re import queue import threading import time +import math import requests.exceptions import voluptuous as vol @@ -220,9 +221,12 @@ def setup(hass, config): json['fields'][key] = float( RE_DECIMAL.sub('', new_value)) - # Infinity is not a valid float in InfluxDB - if (key, float("inf")) in json['fields'].items(): - del json['fields'][key] + # Infinity and NaN are not valid floats in InfluxDB + try: + if not math.isfinite(json['fields'][key]): + del json['fields'][key] + except (KeyError, TypeError): + pass json['tags'].update(tags) diff --git a/homeassistant/components/input_boolean.py b/homeassistant/components/input_boolean.py index 56761b5af4e..9c8435614a2 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -65,8 +65,7 @@ def toggle(hass, entity_id): hass.services.call(DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id}) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up an input boolean.""" component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -85,8 +84,7 @@ def async_setup(hass, config): if not entities: return False - @asyncio.coroutine - def async_handler_service(service): + async def async_handler_service(service): """Handle a calls to the input boolean services.""" target_inputs = component.async_extract_from_service(service) @@ -99,7 +97,7 @@ def async_setup(hass, config): tasks = [getattr(input_b, attr)() for input_b in target_inputs] if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_handler_service, @@ -111,7 +109,7 @@ def async_setup(hass, config): DOMAIN, SERVICE_TOGGLE, async_handler_service, schema=SERVICE_SCHEMA) - yield from component.async_add_entities(entities) + await component.async_add_entities(entities) return True @@ -145,24 +143,21 @@ class InputBoolean(ToggleEntity): """Return true if entity is on.""" return self._state - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Call when entity about to be added to hass.""" # If not None, we got an initial value. if self._state is not None: return - state = yield from async_get_last_state(self.hass, self.entity_id) + state = await async_get_last_state(self.hass, self.entity_id) self._state = state and state.state == STATE_ON - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the entity on.""" self._state = True - yield from self.async_update_ha_state() + await self.async_update_ha_state() - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the entity off.""" self._state = False - yield from self.async_update_ha_state() + await self.async_update_ha_state() diff --git a/homeassistant/components/insteon_plm.py b/homeassistant/components/insteon_plm/__init__.py similarity index 50% rename from homeassistant/components/insteon_plm.py rename to homeassistant/components/insteon_plm/__init__.py index 2381e3db69e..b86f80cbee7 100644 --- a/homeassistant/components/insteon_plm.py +++ b/homeassistant/components/insteon_plm/__init__.py @@ -11,12 +11,13 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.const import (CONF_PORT, EVENT_HOMEASSISTANT_STOP, - CONF_PLATFORM) + CONF_PLATFORM, + CONF_ENTITY_ID) import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['insteonplm==0.8.2'] +REQUIREMENTS = ['insteonplm==0.9.2'] _LOGGER = logging.getLogger(__name__) @@ -29,6 +30,17 @@ CONF_SUBCAT = 'subcat' CONF_FIRMWARE = 'firmware' CONF_PRODUCT_KEY = 'product_key' +SRV_ADD_ALL_LINK = 'add_all_link' +SRV_DEL_ALL_LINK = 'delete_all_link' +SRV_LOAD_ALDB = 'load_all_link_database' +SRV_PRINT_ALDB = 'print_all_link_database' +SRV_PRINT_IM_ALDB = 'print_im_all_link_database' +SRV_ALL_LINK_GROUP = 'group' +SRV_ALL_LINK_MODE = 'mode' +SRV_LOAD_DB_RELOAD = 'reload' +SRV_CONTROLLER = 'controller' +SRV_RESPONDER = 'responder' + CONF_DEVICE_OVERRIDE_SCHEMA = vol.All( cv.deprecated(CONF_PLATFORM), vol.Schema({ vol.Required(CONF_ADDRESS): cv.string, @@ -47,6 +59,24 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) +ADD_ALL_LINK_SCHEMA = vol.Schema({ + vol.Required(SRV_ALL_LINK_GROUP): vol.Range(min=0, max=255), + vol.Required(SRV_ALL_LINK_MODE): vol.In([SRV_CONTROLLER, SRV_RESPONDER]), + }) + +DEL_ALL_LINK_SCHEMA = vol.Schema({ + vol.Required(SRV_ALL_LINK_GROUP): vol.Range(min=0, max=255), + }) + +LOAD_ALDB_SCHEMA = vol.Schema({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(SRV_LOAD_DB_RELOAD, default='false'): cv.boolean, + }) + +PRINT_ALDB_SCHEMA = vol.Schema({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + }) + @asyncio.coroutine def async_setup(hass, config): @@ -54,6 +84,7 @@ def async_setup(hass, config): import insteonplm ipdb = IPDB() + plm = None conf = config[DOMAIN] port = conf.get(CONF_PORT) @@ -64,19 +95,74 @@ def async_setup(hass, config): """Detect device from transport to be delegated to platform.""" for state_key in device.states: platform_info = ipdb[device.states[state_key]] - platform = platform_info.platform - if platform is not None: - _LOGGER.info("New INSTEON PLM device: %s (%s) %s", - device.address, - device.states[state_key].name, - platform) + if platform_info: + platform = platform_info.platform + if platform: + _LOGGER.info("New INSTEON PLM device: %s (%s) %s", + device.address, + device.states[state_key].name, + platform) - hass.async_add_job( - discovery.async_load_platform( - hass, platform, DOMAIN, - discovered={'address': device.address.hex, - 'state_key': state_key}, - hass_config=config)) + hass.async_add_job( + discovery.async_load_platform( + hass, platform, DOMAIN, + discovered={'address': device.address.hex, + 'state_key': state_key}, + hass_config=config)) + + def add_all_link(service): + """Add an INSTEON All-Link between two devices.""" + group = service.data.get(SRV_ALL_LINK_GROUP) + mode = service.data.get(SRV_ALL_LINK_MODE) + link_mode = 1 if mode.lower() == SRV_CONTROLLER else 0 + plm.start_all_linking(link_mode, group) + + def del_all_link(service): + """Delete an INSTEON All-Link between two devices.""" + group = service.data.get(SRV_ALL_LINK_GROUP) + plm.start_all_linking(255, group) + + def load_aldb(service): + """Load the device All-Link database.""" + entity_id = service.data.get(CONF_ENTITY_ID) + reload = service.data.get(SRV_LOAD_DB_RELOAD) + entities = hass.data[DOMAIN].get('entities') + entity = entities.get(entity_id) + if entity: + entity.load_aldb(reload) + else: + _LOGGER.error('Entity %s is not an INSTEON device', entity_id) + + def print_aldb(service): + """Print the All-Link Database for a device.""" + # For now this sends logs to the log file. + # Furture direction is to create an INSTEON control panel. + entity_id = service.data.get(CONF_ENTITY_ID) + entities = hass.data[DOMAIN].get('entities') + entity = entities.get(entity_id) + if entity: + entity.print_aldb() + else: + _LOGGER.error('Entity %s is not an INSTEON device', entity_id) + + def print_im_aldb(service): + """Print the All-Link Database for a device.""" + # For now this sends logs to the log file. + # Furture direction is to create an INSTEON control panel. + print_aldb_to_log(plm.aldb) + + def _register_services(): + hass.services.register(DOMAIN, SRV_ADD_ALL_LINK, add_all_link, + schema=ADD_ALL_LINK_SCHEMA) + hass.services.register(DOMAIN, SRV_DEL_ALL_LINK, del_all_link, + schema=DEL_ALL_LINK_SCHEMA) + hass.services.register(DOMAIN, SRV_LOAD_ALDB, load_aldb, + schema=LOAD_ALDB_SCHEMA) + hass.services.register(DOMAIN, SRV_PRINT_ALDB, print_aldb, + schema=PRINT_ALDB_SCHEMA) + hass.services.register(DOMAIN, SRV_PRINT_IM_ALDB, print_im_aldb, + schema=None) + _LOGGER.debug("Insteon_plm Services registered") _LOGGER.info("Looking for PLM on %s", port) conn = yield from insteonplm.Connection.create( @@ -99,11 +185,14 @@ def async_setup(hass, config): plm.devices.add_override(address, CONF_PRODUCT_KEY, device_override[prop]) - hass.data['insteon_plm'] = plm + hass.data[DOMAIN] = {} + hass.data[DOMAIN]['plm'] = plm + hass.data[DOMAIN]['entities'] = {} hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, conn.close) plm.devices.add_device_callback(async_plm_new_device) + hass.async_add_job(_register_services) return True @@ -127,13 +216,15 @@ class IPDB(object): from insteonplm.states.sensor import (VariableSensor, OnOffSensor, SmokeCO2Sensor, - IoLincSensor) + IoLincSensor, + LeakSensorDryWet) self.states = [State(OnOffSwitch_OutletTop, 'switch'), State(OnOffSwitch_OutletBottom, 'switch'), State(OpenClosedRelay, 'switch'), State(OnOffSwitch, 'switch'), + State(LeakSensorDryWet, 'binary_sensor'), State(IoLincSensor, 'binary_sensor'), State(SmokeCO2Sensor, 'sensor'), State(OnOffSensor, 'binary_sensor'), @@ -166,6 +257,7 @@ class InsteonPLMEntity(Entity): """Initialize the INSTEON PLM binary sensor.""" self._insteon_device_state = device.states[state_key] self._insteon_device = device + self._insteon_device.aldb.add_loaded_callback(self._aldb_loaded) @property def should_poll(self): @@ -212,3 +304,44 @@ class InsteonPLMEntity(Entity): """Register INSTEON update events.""" self._insteon_device_state.register_updates( self.async_entity_update) + self.hass.data[DOMAIN]['entities'][self.entity_id] = self + + def load_aldb(self, reload=False): + """Load the device All-Link Database.""" + if reload: + self._insteon_device.aldb.clear() + self._insteon_device.read_aldb() + + def print_aldb(self): + """Print the device ALDB to the log file.""" + print_aldb_to_log(self._insteon_device.aldb) + + @callback + def _aldb_loaded(self): + """All-Link Database loaded for the device.""" + self.print_aldb() + + +def print_aldb_to_log(aldb): + """Print the All-Link Database to the log file.""" + from insteonplm.devices import ALDBStatus + _LOGGER.info('ALDB load status is %s', aldb.status.name) + if aldb.status not in [ALDBStatus.LOADED, ALDBStatus.PARTIAL]: + _LOGGER.warning('Device All-Link database not loaded') + _LOGGER.warning('Use service insteon_plm.load_aldb first') + return + + _LOGGER.info('RecID In Use Mode HWM Group Address Data 1 Data 2 Data 3') + _LOGGER.info('----- ------ ---- --- ----- -------- ------ ------ ------') + for mem_addr in aldb: + rec = aldb[mem_addr] + # For now we write this to the log + # Roadmap is to create a configuration panel + in_use = 'Y' if rec.control_flags.is_in_use else 'N' + mode = 'C' if rec.control_flags.is_controller else 'R' + hwm = 'Y' if rec.control_flags.is_high_water_mark else 'N' + _LOGGER.info(' {:04x} {:s} {:s} {:s} {:3d} {:s}' + ' {:3d} {:3d} {:3d}'.format( + rec.mem_addr, in_use, mode, hwm, + rec.group, rec.address.human, + rec.data1, rec.data2, rec.data3)) diff --git a/homeassistant/components/insteon_plm/services.yaml b/homeassistant/components/insteon_plm/services.yaml new file mode 100644 index 00000000000..9ea53c10fbf --- /dev/null +++ b/homeassistant/components/insteon_plm/services.yaml @@ -0,0 +1,32 @@ +add_all_link: + description: Tells the Insteom Modem (IM) start All-Linking mode. Once the the IM is in All-Linking mode, press the link button on the device to complete All-Linking. + fields: + group: + description: All-Link group number. + example: 1 + mode: + description: Linking mode controller - IM is controller responder - IM is responder + example: 'controller' +delete_all_link: + description: Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link Database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process. + fields: + group: + description: All-Link group number. + example: 1 +load_all_link_database: + description: Load the All-Link Database for a device. WARNING - Loading a device All-LInk database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records. + fields: + entity_id: + description: Name of the device to print + example: 'light.1a2b3c' + reload: + description: Reload all records. If true the current records are cleared from memory (does not effect the device) and the records are reloaded. If false the existing records are left in place and only missing records are added. Default is false. + example: 'true' +print_all_link_database: + description: Print the All-Link Database for a device. Requires that the All-Link Database is loaded into memory. + fields: + entity_id: + description: Name of the device to print + example: 'light.1a2b3c' +print_im_all_link_database: + description: Print the All-Link Database for the INSTEON Modem (IM). diff --git a/homeassistant/components/iota.py b/homeassistant/components/iota.py index 442be6e22e7..ada70f8a9eb 100644 --- a/homeassistant/components/iota.py +++ b/homeassistant/components/iota.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyota==2.0.4'] +REQUIREMENTS = ['pyota==2.0.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 48a9499d1a9..ecabcd36a85 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -202,7 +202,7 @@ def _check_for_uom_id(hass: HomeAssistant, node, node_uom = set(map(str.lower, node.uom)) if uom_list: - if node_uom.intersection(NODE_FILTERS[single_domain]['uom']): + if node_uom.intersection(uom_list): hass.data[ISY994_NODES][single_domain].append(node) return True else: diff --git a/homeassistant/components/keyboard_remote.py b/homeassistant/components/keyboard_remote.py index d737c555873..af45bd3d4f9 100644 --- a/homeassistant/components/keyboard_remote.py +++ b/homeassistant/components/keyboard_remote.py @@ -50,10 +50,7 @@ def setup(hass, config): """Set up the keyboard_remote.""" config = config.get(DOMAIN) - keyboard_remote = KeyboardRemote( - hass, - config - ) + keyboard_remote = KeyboardRemote(hass, config) def _start_keyboard_remote(_event): keyboard_remote.run() @@ -61,14 +58,8 @@ def setup(hass, config): def _stop_keyboard_remote(_event): keyboard_remote.stop() - hass.bus.listen_once( - EVENT_HOMEASSISTANT_START, - _start_keyboard_remote - ) - hass.bus.listen_once( - EVENT_HOMEASSISTANT_STOP, - _stop_keyboard_remote - ) + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_keyboard_remote) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_keyboard_remote) return True @@ -93,10 +84,8 @@ class KeyboardRemoteThread(threading.Thread): _LOGGER.debug("Keyboard connected, %s", self.device_id) else: _LOGGER.debug( - 'Keyboard not connected, %s.\n\ - Check /dev/input/event* permissions.', - self.device_id - ) + "Keyboard not connected, %s. " + "Check /dev/input/event* permissions", self.device_id) id_folder = '/dev/input/by-id/' @@ -105,12 +94,9 @@ class KeyboardRemoteThread(threading.Thread): device_names = [InputDevice(file_name).name for file_name in list_devices()] _LOGGER.debug( - 'Possible device names are:\n %s.\n \ - Possible device descriptors are %s:\n %s', - device_names, - id_folder, - os.listdir(id_folder) - ) + "Possible device names are: %s. " + "Possible device descriptors are %s: %s", + device_names, id_folder, os.listdir(id_folder)) threading.Thread.__init__(self) self.stopped = threading.Event() @@ -149,9 +135,7 @@ class KeyboardRemoteThread(threading.Thread): self.dev = self._get_keyboard_device() if self.dev is not None: self.dev.grab() - self.hass.bus.fire( - KEYBOARD_REMOTE_CONNECTED - ) + self.hass.bus.fire(KEYBOARD_REMOTE_CONNECTED) _LOGGER.debug("Keyboard re-connected, %s", self.device_id) else: continue @@ -160,9 +144,7 @@ class KeyboardRemoteThread(threading.Thread): event = self.dev.read_one() except IOError: # Keyboard Disconnected self.dev = None - self.hass.bus.fire( - KEYBOARD_REMOTE_DISCONNECTED - ) + self.hass.bus.fire(KEYBOARD_REMOTE_DISCONNECTED) _LOGGER.debug("Keyboard disconnected, %s", self.device_id) continue @@ -174,7 +156,11 @@ class KeyboardRemoteThread(threading.Thread): _LOGGER.debug(categorize(event)) self.hass.bus.fire( KEYBOARD_REMOTE_COMMAND_RECEIVED, - {KEY_CODE: event.code} + { + KEY_CODE: event.code, + DEVICE_DESCRIPTOR: self.device_descriptor, + DEVICE_NAME: self.device_name + } ) @@ -191,9 +177,8 @@ class KeyboardRemote(object): if device_descriptor is not None\ or device_name is not None: - thread = KeyboardRemoteThread(hass, device_name, - device_descriptor, - key_value) + thread = KeyboardRemoteThread( + hass, device_name, device_descriptor, key_value) self.threads.append(thread) def run(self): diff --git a/homeassistant/components/konnected.py b/homeassistant/components/konnected.py new file mode 100644 index 00000000000..70b66f84ae9 --- /dev/null +++ b/homeassistant/components/konnected.py @@ -0,0 +1,319 @@ +""" +Support for Konnected devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/konnected/ +""" +import logging +import hmac +import json +import voluptuous as vol + +from aiohttp.hdrs import AUTHORIZATION +from aiohttp.web import Request, Response # NOQA + +from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA +from homeassistant.components.discovery import SERVICE_KONNECTED +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, HTTP_UNAUTHORIZED, + CONF_DEVICES, CONF_BINARY_SENSORS, CONF_SWITCHES, CONF_HOST, CONF_PORT, + CONF_ID, CONF_NAME, CONF_TYPE, CONF_PIN, CONF_ZONE, CONF_ACCESS_TOKEN, + ATTR_ENTITY_ID, ATTR_STATE) +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['konnected==0.1.2'] + +DOMAIN = 'konnected' + +CONF_ACTIVATION = 'activation' +STATE_LOW = 'low' +STATE_HIGH = 'high' + +PIN_TO_ZONE = {1: 1, 2: 2, 5: 3, 6: 4, 7: 5, 8: 'out', 9: 6} +ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()} + +_BINARY_SENSOR_SCHEMA = vol.All( + vol.Schema({ + vol.Exclusive(CONF_PIN, 's_pin'): vol.Any(*PIN_TO_ZONE), + vol.Exclusive(CONF_ZONE, 's_pin'): vol.Any(*ZONE_TO_PIN), + vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_NAME): cv.string, + }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE) +) + +_SWITCH_SCHEMA = vol.All( + vol.Schema({ + vol.Exclusive(CONF_PIN, 'a_pin'): vol.Any(*PIN_TO_ZONE), + vol.Exclusive(CONF_ZONE, 'a_pin'): vol.Any(*ZONE_TO_PIN), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): + vol.All(vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)) + }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE) +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_DEVICES): [{ + vol.Required(CONF_ID): cv.string, + vol.Optional(CONF_BINARY_SENSORS): vol.All( + cv.ensure_list, [_BINARY_SENSOR_SCHEMA]), + vol.Optional(CONF_SWITCHES): vol.All( + cv.ensure_list, [_SWITCH_SCHEMA]), + }], + }), + }, + extra=vol.ALLOW_EXTRA, +) + +DEPENDENCIES = ['http', 'discovery'] + +ENDPOINT_ROOT = '/api/konnected' +UPDATE_ENDPOINT = (ENDPOINT_ROOT + r'/device/{device_id:[a-zA-Z0-9]+}') +SIGNAL_SENSOR_UPDATE = 'konnected.{}.update' + + +async def async_setup(hass, config): + """Set up the Konnected platform.""" + cfg = config.get(DOMAIN) + if cfg is None: + cfg = {} + + access_token = cfg.get(CONF_ACCESS_TOKEN) + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {CONF_ACCESS_TOKEN: access_token} + + def device_discovered(service, info): + """Call when a Konnected device has been discovered.""" + _LOGGER.debug("Discovered a new Konnected device: %s", info) + host = info.get(CONF_HOST) + port = info.get(CONF_PORT) + + device = KonnectedDevice(hass, host, port, cfg) + device.setup() + + discovery.async_listen( + hass, + SERVICE_KONNECTED, + device_discovered) + + hass.http.register_view(KonnectedView(access_token)) + + return True + + +class KonnectedDevice(object): + """A representation of a single Konnected device.""" + + def __init__(self, hass, host, port, config): + """Initialize the Konnected device.""" + self.hass = hass + self.host = host + self.port = port + self.user_config = config + + import konnected + self.client = konnected.Client(host, str(port)) + self.status = self.client.get_status() + _LOGGER.info('Initialized Konnected device %s', self.device_id) + + def setup(self): + """Set up a newly discovered Konnected device.""" + user_config = self.config() + if user_config: + _LOGGER.debug('Configuring Konnected device %s', self.device_id) + self.save_data() + self.sync_device_config() + discovery.load_platform( + self.hass, 'binary_sensor', + DOMAIN, {'device_id': self.device_id}) + discovery.load_platform( + self.hass, 'switch', DOMAIN, + {'device_id': self.device_id}) + + @property + def device_id(self): + """Device id is the MAC address as string with punctuation removed.""" + return self.status['mac'].replace(':', '') + + def config(self): + """Return an object representing the user defined configuration.""" + device_id = self.device_id + valid_keys = [device_id, device_id.upper(), + device_id[6:], device_id.upper()[6:]] + configured_devices = self.user_config[CONF_DEVICES] + return next((device for device in + configured_devices if device[CONF_ID] in valid_keys), + None) + + def save_data(self): + """Save the device configuration to `hass.data`.""" + sensors = {} + for entity in self.config().get(CONF_BINARY_SENSORS) or []: + if CONF_ZONE in entity: + pin = ZONE_TO_PIN[entity[CONF_ZONE]] + else: + pin = entity[CONF_PIN] + + sensor_status = next((sensor for sensor in + self.status.get('sensors') if + sensor.get(CONF_PIN) == pin), {}) + if sensor_status.get(ATTR_STATE): + initial_state = bool(int(sensor_status.get(ATTR_STATE))) + else: + initial_state = None + + sensors[pin] = { + CONF_TYPE: entity[CONF_TYPE], + CONF_NAME: entity.get(CONF_NAME, 'Konnected {} Zone {}'.format( + self.device_id[6:], PIN_TO_ZONE[pin])), + ATTR_STATE: initial_state + } + _LOGGER.debug('Set up sensor %s (initial state: %s)', + sensors[pin].get('name'), + sensors[pin].get(ATTR_STATE)) + + actuators = {} + for entity in self.config().get(CONF_SWITCHES) or []: + if 'zone' in entity: + pin = ZONE_TO_PIN[entity['zone']] + else: + pin = entity['pin'] + + actuator_status = next((actuator for actuator in + self.status.get('actuators') if + actuator.get('pin') == pin), {}) + if actuator_status.get(ATTR_STATE): + initial_state = bool(int(actuator_status.get(ATTR_STATE))) + else: + initial_state = None + + actuators[pin] = { + CONF_NAME: entity.get( + CONF_NAME, 'Konnected {} Actuator {}'.format( + self.device_id[6:], PIN_TO_ZONE[pin])), + ATTR_STATE: initial_state, + CONF_ACTIVATION: entity[CONF_ACTIVATION], + } + _LOGGER.debug('Set up actuator %s (initial state: %s)', + actuators[pin].get(CONF_NAME), + actuators[pin].get(ATTR_STATE)) + + device_data = { + 'client': self.client, + CONF_BINARY_SENSORS: sensors, + CONF_SWITCHES: actuators, + CONF_HOST: self.host, + CONF_PORT: self.port, + } + + if CONF_DEVICES not in self.hass.data[DOMAIN]: + self.hass.data[DOMAIN][CONF_DEVICES] = {} + + _LOGGER.debug('Storing data in hass.data[konnected]: %s', device_data) + self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data + + @property + def stored_configuration(self): + """Return the configuration stored in `hass.data` for this device.""" + return self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] + + def sensor_configuration(self): + """Return the configuration map for syncing sensors.""" + return [{'pin': p} for p in + self.stored_configuration[CONF_BINARY_SENSORS]] + + def actuator_configuration(self): + """Return the configuration map for syncing actuators.""" + return [{'pin': p, + 'trigger': (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW] + else 1)} + for p, data in + self.stored_configuration[CONF_SWITCHES].items()] + + def sync_device_config(self): + """Sync the new pin configuration to the Konnected device.""" + desired_sensor_configuration = self.sensor_configuration() + current_sensor_configuration = [ + {'pin': s[CONF_PIN]} for s in self.status.get('sensors')] + _LOGGER.debug('%s: desired sensor config: %s', self.device_id, + desired_sensor_configuration) + _LOGGER.debug('%s: current sensor config: %s', self.device_id, + current_sensor_configuration) + + desired_actuator_config = self.actuator_configuration() + current_actuator_config = self.status.get('actuators') + _LOGGER.debug('%s: desired actuator config: %s', self.device_id, + desired_actuator_config) + _LOGGER.debug('%s: current actuator config: %s', self.device_id, + current_actuator_config) + + if (desired_sensor_configuration != current_sensor_configuration) or \ + (current_actuator_config != desired_actuator_config): + _LOGGER.debug('pushing settings to device %s', self.device_id) + self.client.put_settings( + desired_sensor_configuration, + desired_actuator_config, + self.hass.data[DOMAIN].get(CONF_ACCESS_TOKEN), + self.hass.config.api.base_url + ENDPOINT_ROOT + ) + + +class KonnectedView(HomeAssistantView): + """View creates an endpoint to receive push updates from the device.""" + + url = UPDATE_ENDPOINT + extra_urls = [UPDATE_ENDPOINT + '/{pin_num}/{state}'] + name = 'api:konnected' + requires_auth = False # Uses access token from configuration + + def __init__(self, auth_token): + """Initialize the view.""" + self.auth_token = auth_token + + async def put(self, request: Request, device_id, + pin_num=None, state=None) -> Response: + """Receive a sensor update via PUT request and async set state.""" + hass = request.app['hass'] + data = hass.data[DOMAIN] + + try: # Konnected 2.2.0 and above supports JSON payloads + payload = await request.json() + pin_num = payload['pin'] + state = payload['state'] + except json.decoder.JSONDecodeError: + _LOGGER.warning(("Your Konnected device software may be out of " + "date. Visit https://help.konnected.io for " + "updating instructions.")) + + auth = request.headers.get(AUTHORIZATION, None) + if not hmac.compare_digest('Bearer {}'.format(self.auth_token), auth): + return self.json_message( + "unauthorized", status_code=HTTP_UNAUTHORIZED) + pin_num = int(pin_num) + state = bool(int(state)) + device = data[CONF_DEVICES].get(device_id) + if device is None: + return self.json_message('unregistered device', + status_code=HTTP_BAD_REQUEST) + pin_data = device[CONF_BINARY_SENSORS].get(pin_num) or \ + device[CONF_SWITCHES].get(pin_num) + + if pin_data is None: + return self.json_message('unregistered sensor/actuator', + status_code=HTTP_BAD_REQUEST) + + entity_id = pin_data.get(ATTR_ENTITY_ID) + if entity_id is None: + return self.json_message('uninitialized sensor/actuator', + status_code=HTTP_INTERNAL_SERVER_ERROR) + + async_dispatcher_send( + hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state) + return self.json_message('ok') diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index a3a962a7e34..30a1a800a44 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -40,9 +40,8 @@ SUPPORT_BRIGHTNESS = 1 SUPPORT_COLOR_TEMP = 2 SUPPORT_EFFECT = 4 SUPPORT_FLASH = 8 -SUPPORT_RGB_COLOR = 16 +SUPPORT_COLOR = 16 SUPPORT_TRANSITION = 32 -SUPPORT_XY_COLOR = 64 SUPPORT_WHITE_VALUE = 128 # Integer that represents transition time in seconds to make change. @@ -51,6 +50,7 @@ ATTR_TRANSITION = "transition" # Lists holding color values ATTR_RGB_COLOR = "rgb_color" ATTR_XY_COLOR = "xy_color" +ATTR_HS_COLOR = "hs_color" ATTR_COLOR_TEMP = "color_temp" ATTR_KELVIN = "kelvin" ATTR_MIN_MIREDS = "min_mireds" @@ -86,8 +86,9 @@ LIGHT_PROFILES_FILE = "light_profiles.csv" PROP_TO_ATTR = { 'brightness': ATTR_BRIGHTNESS, 'color_temp': ATTR_COLOR_TEMP, - 'rgb_color': ATTR_RGB_COLOR, - 'xy_color': ATTR_XY_COLOR, + 'min_mireds': ATTR_MIN_MIREDS, + 'max_mireds': ATTR_MAX_MIREDS, + 'hs_color': ATTR_HS_COLOR, 'white_value': ATTR_WHITE_VALUE, 'effect_list': ATTR_EFFECT_LIST, 'effect': ATTR_EFFECT, @@ -111,6 +112,11 @@ LIGHT_TURN_ON_SCHEMA = vol.Schema({ vol.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): vol.All(vol.ExactSequence((cv.small_float, cv.small_float)), vol.Coerce(tuple)), + vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): + vol.All(vol.ExactSequence( + (vol.All(vol.Coerce(float), vol.Range(min=0, max=360)), + vol.All(vol.Coerce(float), vol.Range(min=0, max=100)))), + vol.Coerce(tuple)), vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Exclusive(ATTR_KELVIN, COLOR_GROUP): @@ -149,13 +155,13 @@ def is_on(hass, entity_id=None): @bind_hass def turn_on(hass, entity_id=None, transition=None, brightness=None, - brightness_pct=None, rgb_color=None, xy_color=None, + brightness_pct=None, rgb_color=None, xy_color=None, hs_color=None, color_temp=None, kelvin=None, white_value=None, profile=None, flash=None, effect=None, color_name=None): """Turn all or specified light on.""" hass.add_job( async_turn_on, hass, entity_id, transition, brightness, brightness_pct, - rgb_color, xy_color, color_temp, kelvin, white_value, + rgb_color, xy_color, hs_color, color_temp, kelvin, white_value, profile, flash, effect, color_name) @@ -163,8 +169,9 @@ def turn_on(hass, entity_id=None, transition=None, brightness=None, @bind_hass def async_turn_on(hass, entity_id=None, transition=None, brightness=None, brightness_pct=None, rgb_color=None, xy_color=None, - color_temp=None, kelvin=None, white_value=None, - profile=None, flash=None, effect=None, color_name=None): + hs_color=None, color_temp=None, kelvin=None, + white_value=None, profile=None, flash=None, effect=None, + color_name=None): """Turn all or specified light on.""" data = { key: value for key, value in [ @@ -175,6 +182,7 @@ def async_turn_on(hass, entity_id=None, transition=None, brightness=None, (ATTR_BRIGHTNESS_PCT, brightness_pct), (ATTR_RGB_COLOR, rgb_color), (ATTR_XY_COLOR, xy_color), + (ATTR_HS_COLOR, hs_color), (ATTR_COLOR_TEMP, color_temp), (ATTR_KELVIN, kelvin), (ATTR_WHITE_VALUE, white_value), @@ -254,6 +262,14 @@ def preprocess_turn_on_alternatives(params): if brightness_pct is not None: params[ATTR_BRIGHTNESS] = int(255 * brightness_pct/100) + xy_color = params.pop(ATTR_XY_COLOR, None) + if xy_color is not None: + params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color) + + rgb_color = params.pop(ATTR_RGB_COLOR, None) + if rgb_color is not None: + params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) + class SetIntentHandler(intent.IntentHandler): """Handle set color intents.""" @@ -281,7 +297,7 @@ class SetIntentHandler(intent.IntentHandler): if 'color' in slots: intent.async_test_feature( - state, SUPPORT_RGB_COLOR, 'changing colors') + state, SUPPORT_COLOR, 'changing colors') service_data[ATTR_RGB_COLOR] = slots['color']['value'] # Use original passed in value of the color because we don't have # human readable names for that internally. @@ -318,7 +334,7 @@ class SetIntentHandler(intent.IntentHandler): async def async_setup(hass, config): """Expose light control via state machine and services.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LIGHTS) await component.async_setup(config) @@ -372,6 +388,16 @@ async def async_setup(hass, 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) + + class Profiles: """Representation of available color profiles.""" @@ -428,13 +454,8 @@ class Light(ToggleEntity): return None @property - def xy_color(self): - """Return the XY color value [float, float].""" - return None - - @property - def rgb_color(self): - """Return the RGB color value [int, int, int].""" + def hs_color(self): + """Return the hue and saturation color value [float, float].""" return None @property @@ -446,12 +467,14 @@ class Light(ToggleEntity): def min_mireds(self): """Return the coldest color_temp that this light supports.""" # Default to the Philips Hue value that HA has always assumed - return 154 + # https://developers.meethue.com/documentation/core-concepts + return 153 @property def max_mireds(self): """Return the warmest color_temp that this light supports.""" # Default to the Philips Hue value that HA has always assumed + # https://developers.meethue.com/documentation/core-concepts return 500 @property @@ -484,11 +507,16 @@ class Light(ToggleEntity): if value is not None: data[attr] = value - if ATTR_RGB_COLOR not in data and ATTR_XY_COLOR in data and \ - ATTR_BRIGHTNESS in data: - data[ATTR_RGB_COLOR] = color_util.color_xy_brightness_to_RGB( - data[ATTR_XY_COLOR][0], data[ATTR_XY_COLOR][1], - data[ATTR_BRIGHTNESS]) + # Expose current color also as RGB and XY + if ATTR_HS_COLOR in data: + data[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB( + *data[ATTR_HS_COLOR]) + data[ATTR_XY_COLOR] = color_util.color_hs_to_xy( + *data[ATTR_HS_COLOR]) + data[ATTR_HS_COLOR] = ( + round(data[ATTR_HS_COLOR][0], 3), + round(data[ATTR_HS_COLOR][1], 3), + ) return data diff --git a/homeassistant/components/light/abode.py b/homeassistant/components/light/abode.py index d3e79b38647..8b7e09d86bc 100644 --- a/homeassistant/components/light/abode.py +++ b/homeassistant/components/light/abode.py @@ -5,11 +5,12 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.abode/ """ import logging - +from math import ceil from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, - SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, Light) + ATTR_BRIGHTNESS, ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light) +import homeassistant.util.color as color_util DEPENDENCIES = ['abode'] @@ -44,11 +45,15 @@ class AbodeLight(AbodeDevice, Light): def turn_on(self, **kwargs): """Turn on the light.""" - if (ATTR_RGB_COLOR in kwargs and + if (ATTR_HS_COLOR in kwargs and self._device.is_dimmable and self._device.has_color): - self._device.set_color(kwargs[ATTR_RGB_COLOR]) - elif ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable: - self._device.set_level(kwargs[ATTR_BRIGHTNESS]) + self._device.set_color(color_util.color_hs_to_RGB( + *kwargs[ATTR_HS_COLOR])) + + if ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable: + # Convert HASS brightness (0-255) to Abode brightness (0-99) + # If 100 is sent to Abode, response is 99 causing an error + self._device.set_level(ceil(kwargs[ATTR_BRIGHTNESS] * 99 / 255.0)) else: self._device.switch_on() @@ -65,19 +70,24 @@ class AbodeLight(AbodeDevice, Light): def brightness(self): """Return the brightness of the light.""" if self._device.is_dimmable and self._device.has_brightness: - return self._device.brightness + brightness = int(self._device.brightness) + # Abode returns 100 during device initialization and device refresh + if brightness == 100: + return 255 + # Convert Abode brightness (0-99) to HASS brightness (0-255) + return ceil(brightness * 255 / 99.0) @property - def rgb_color(self): + def hs_color(self): """Return the color of the light.""" if self._device.is_dimmable and self._device.has_color: - return self._device.color + return color_util.color_RGB_to_hs(*self._device.color) @property def supported_features(self): """Flag supported features.""" if self._device.is_dimmable and self._device.has_color: - return SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR elif self._device.is_dimmable: return SUPPORT_BRIGHTNESS diff --git a/homeassistant/components/light/blinksticklight.py b/homeassistant/components/light/blinksticklight.py index d6a6ef465a8..18a6b4ae266 100644 --- a/homeassistant/components/light/blinksticklight.py +++ b/homeassistant/components/light/blinksticklight.py @@ -9,9 +9,11 @@ import logging import voluptuous as vol from homeassistant.components.light import ( - ATTR_RGB_COLOR, SUPPORT_RGB_COLOR, Light, PLATFORM_SCHEMA) + ATTR_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light, + PLATFORM_SCHEMA) from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util REQUIREMENTS = ['blinkstick==1.1.8'] @@ -21,7 +23,7 @@ CONF_SERIAL = 'serial' DEFAULT_NAME = 'Blinkstick' -SUPPORT_BLINKSTICK = SUPPORT_RGB_COLOR +SUPPORT_BLINKSTICK = SUPPORT_BRIGHTNESS | SUPPORT_COLOR PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SERIAL): cv.string, @@ -39,7 +41,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): stick = blinkstick.find_by_serial(serial) - add_devices([BlinkStickLight(stick, name)]) + add_devices([BlinkStickLight(stick, name)], True) class BlinkStickLight(Light): @@ -50,7 +52,8 @@ class BlinkStickLight(Light): self._stick = stick self._name = name self._serial = stick.get_serial() - self._rgb_color = stick.get_color() + self._hs_color = None + self._brightness = None @property def should_poll(self): @@ -63,14 +66,19 @@ class BlinkStickLight(Light): return self._name @property - def rgb_color(self): + def brightness(self): + """Read back the brightness of the light.""" + return self._brightness + + @property + def hs_color(self): """Read back the color of the light.""" - return self._rgb_color + return self._hs_color @property def is_on(self): - """Check whether any of the LEDs colors are non-zero.""" - return sum(self._rgb_color) > 0 + """Return True if entity is on.""" + return self._brightness > 0 @property def supported_features(self): @@ -79,18 +87,24 @@ class BlinkStickLight(Light): def update(self): """Read back the device state.""" - self._rgb_color = self._stick.get_color() + rgb_color = self._stick.get_color() + hsv = color_util.color_RGB_to_hsv(*rgb_color) + self._hs_color = hsv[:2] + self._brightness = hsv[2] def turn_on(self, **kwargs): """Turn the device on.""" - if ATTR_RGB_COLOR in kwargs: - self._rgb_color = kwargs[ATTR_RGB_COLOR] + if ATTR_HS_COLOR in kwargs: + self._hs_color = kwargs[ATTR_HS_COLOR] + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] else: - self._rgb_color = [255, 255, 255] + self._brightness = 255 - self._stick.set_color(red=self._rgb_color[0], - green=self._rgb_color[1], - blue=self._rgb_color[2]) + rgb_color = color_util.color_hsv_to_RGB( + self._hs_color[0], self._hs_color[1], self._brightness / 255 * 100) + self._stick.set_color( + red=rgb_color[0], green=rgb_color[1], blue=rgb_color[2]) def turn_off(self, **kwargs): """Turn the device off.""" diff --git a/homeassistant/components/light/blinkt.py b/homeassistant/components/light/blinkt.py index db3171cf4cf..97edd7c54d2 100644 --- a/homeassistant/components/light/blinkt.py +++ b/homeassistant/components/light/blinkt.py @@ -10,15 +10,16 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, ATTR_RGB_COLOR, SUPPORT_RGB_COLOR, + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_COLOR, Light, PLATFORM_SCHEMA) from homeassistant.const import CONF_NAME +import homeassistant.util.color as color_util REQUIREMENTS = ['blinkt==0.1.0'] _LOGGER = logging.getLogger(__name__) -SUPPORT_BLINKT = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR) +SUPPORT_BLINKT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR) DEFAULT_NAME = 'blinkt' @@ -55,7 +56,7 @@ class BlinktLight(Light): self._index = index self._is_on = False self._brightness = 255 - self._rgb_color = [255, 255, 255] + self._hs_color = [0, 0] @property def name(self): @@ -71,12 +72,9 @@ class BlinktLight(Light): return self._brightness @property - def rgb_color(self): - """Read back the color of the light. - - Returns [r, g, b] list with values in range of 0-255. - """ - return self._rgb_color + def hs_color(self): + """Read back the color of the light.""" + return self._hs_color @property def supported_features(self): @@ -100,16 +98,17 @@ class BlinktLight(Light): def turn_on(self, **kwargs): """Instruct the light to turn on and set correct brightness & color.""" - if ATTR_RGB_COLOR in kwargs: - self._rgb_color = kwargs[ATTR_RGB_COLOR] + if ATTR_HS_COLOR in kwargs: + self._hs_color = kwargs[ATTR_HS_COLOR] if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] percent_bright = (self._brightness / 255) + rgb_color = color_util.color_hs_to_RGB(*self._hs_color) self._blinkt.set_pixel(self._index, - self._rgb_color[0], - self._rgb_color[1], - self._rgb_color[2], + rgb_color[0], + rgb_color[1], + rgb_color[2], percent_bright) self._blinkt.show() diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index a3e54434109..916e60c00b1 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -5,35 +5,50 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/light.deconz/ """ from homeassistant.components.deconz import ( - DOMAIN as DATA_DECONZ, DATA_DECONZ_ID) + DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, DATA_DECONZ_UNSUB) from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, - ATTR_TRANSITION, ATTR_XY_COLOR, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, - SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, SUPPORT_XY_COLOR, Light) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, + ATTR_TRANSITION, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, + SUPPORT_FLASH, SUPPORT_TRANSITION, Light) from homeassistant.core import callback -from homeassistant.util.color import color_RGB_to_xy +from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.util.color as color_util DEPENDENCIES = ['deconz'] async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the deCONZ light.""" - if discovery_info is None: - return + """Old way of setting up deCONZ lights and group.""" + pass - lights = hass.data[DATA_DECONZ].lights - groups = hass.data[DATA_DECONZ].groups - entities = [] - for light in lights.values(): - entities.append(DeconzLight(light)) +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the deCONZ lights and groups from a config entry.""" + @callback + def async_add_light(lights): + """Add light from deCONZ.""" + entities = [] + for light in lights: + entities.append(DeconzLight(light)) + async_add_devices(entities, True) + hass.data[DATA_DECONZ_UNSUB].append( + async_dispatcher_connect(hass, 'deconz_new_light', async_add_light)) - for group in groups.values(): - if group.lights: # Don't create entity for group not containing light - entities.append(DeconzLight(group)) - async_add_devices(entities, True) + @callback + def async_add_group(groups): + """Add group from deCONZ.""" + entities = [] + for group in groups: + if group.lights: + entities.append(DeconzLight(group)) + async_add_devices(entities, True) + hass.data[DATA_DECONZ_UNSUB].append( + async_dispatcher_connect(hass, 'deconz_new_group', async_add_group)) + + async_add_light(hass.data[DATA_DECONZ].lights.values()) + async_add_group(hass.data[DATA_DECONZ].groups.values()) class DeconzLight(Light): @@ -51,8 +66,7 @@ class DeconzLight(Light): self._features |= SUPPORT_COLOR_TEMP if self._light.xy is not None: - self._features |= SUPPORT_RGB_COLOR - self._features |= SUPPORT_XY_COLOR + self._features |= SUPPORT_COLOR if self._light.effect is not None: self._features |= SUPPORT_EFFECT @@ -124,14 +138,8 @@ class DeconzLight(Light): if ATTR_COLOR_TEMP in kwargs: data['ct'] = kwargs[ATTR_COLOR_TEMP] - if ATTR_RGB_COLOR in kwargs: - xyb = color_RGB_to_xy( - *(int(val) for val in kwargs[ATTR_RGB_COLOR])) - data['xy'] = xyb[0], xyb[1] - data['bri'] = xyb[2] - - if ATTR_XY_COLOR in kwargs: - data['xy'] = kwargs[ATTR_XY_COLOR] + if ATTR_HS_COLOR in kwargs: + data['xy'] = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) if ATTR_BRIGHTNESS in kwargs: data['bri'] = kwargs[ATTR_BRIGHTNESS] diff --git a/homeassistant/components/light/demo.py b/homeassistant/components/light/demo.py index acc70a57ff4..ba27cbd3ac5 100644 --- a/homeassistant/components/light/demo.py +++ b/homeassistant/components/light/demo.py @@ -7,14 +7,13 @@ https://home-assistant.io/components/demo/ import random from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, - ATTR_RGB_COLOR, ATTR_WHITE_VALUE, ATTR_XY_COLOR, SUPPORT_BRIGHTNESS, - SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_RGB_COLOR, SUPPORT_WHITE_VALUE, - Light) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, + ATTR_WHITE_VALUE, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, + SUPPORT_COLOR, SUPPORT_WHITE_VALUE, Light) LIGHT_COLORS = [ - [237, 224, 33], - [255, 63, 111], + (56, 86), + (345, 75), ] LIGHT_EFFECT_LIST = ['rainbow', 'none'] @@ -22,7 +21,7 @@ LIGHT_EFFECT_LIST = ['rainbow', 'none'] LIGHT_TEMPS = [240, 380] SUPPORT_DEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | - SUPPORT_RGB_COLOR | SUPPORT_WHITE_VALUE) + SUPPORT_COLOR | SUPPORT_WHITE_VALUE) def setup_platform(hass, config, add_devices_callback, discovery_info=None): @@ -40,20 +39,20 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class DemoLight(Light): """Representation of a demo light.""" - def __init__(self, unique_id, name, state, available=False, rgb=None, - ct=None, brightness=180, xy_color=(.5, .5), white=200, - effect_list=None, effect=None): + def __init__(self, unique_id, name, state, available=False, hs_color=None, + ct=None, brightness=180, white=200, effect_list=None, + effect=None): """Initialize the light.""" self._unique_id = unique_id self._name = name self._state = state - self._rgb = rgb + self._hs_color = hs_color self._ct = ct or random.choice(LIGHT_TEMPS) self._brightness = brightness - self._xy_color = xy_color self._white = white self._effect_list = effect_list self._effect = effect + self._available = True @property def should_poll(self) -> bool: @@ -75,7 +74,7 @@ class DemoLight(Light): """Return availability.""" # This demo light is always available, but well-behaving components # should implement this to inform Home Assistant accordingly. - return True + return self._available @property def brightness(self) -> int: @@ -83,14 +82,9 @@ class DemoLight(Light): return self._brightness @property - def xy_color(self) -> tuple: - """Return the XY color value [float, float].""" - return self._xy_color - - @property - def rgb_color(self) -> tuple: - """Return the RBG color value.""" - return self._rgb + def hs_color(self) -> tuple: + """Return the hs color value.""" + return self._hs_color @property def color_temp(self) -> int: @@ -126,8 +120,8 @@ class DemoLight(Light): """Turn the light on.""" self._state = True - if ATTR_RGB_COLOR in kwargs: - self._rgb = kwargs[ATTR_RGB_COLOR] + if ATTR_HS_COLOR in kwargs: + self._hs_color = kwargs[ATTR_HS_COLOR] if ATTR_COLOR_TEMP in kwargs: self._ct = kwargs[ATTR_COLOR_TEMP] @@ -135,9 +129,6 @@ class DemoLight(Light): if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_XY_COLOR in kwargs: - self._xy_color = kwargs[ATTR_XY_COLOR] - if ATTR_WHITE_VALUE in kwargs: self._white = kwargs[ATTR_WHITE_VALUE] diff --git a/homeassistant/components/light/eufy.py b/homeassistant/components/light/eufy.py new file mode 100644 index 00000000000..6f0a8816eea --- /dev/null +++ b/homeassistant/components/light/eufy.py @@ -0,0 +1,171 @@ +""" +Support for Eufy lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.eufy/ +""" +import logging + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR_TEMP, SUPPORT_COLOR, Light) + +import homeassistant.util.color as color_util + +from homeassistant.util.color import ( + color_temperature_mired_to_kelvin as mired_to_kelvin, + color_temperature_kelvin_to_mired as kelvin_to_mired) + +DEPENDENCIES = ['eufy'] + +_LOGGER = logging.getLogger(__name__) + +EUFY_MAX_KELVIN = 6500 +EUFY_MIN_KELVIN = 2700 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Eufy bulbs.""" + if discovery_info is None: + return + add_devices([EufyLight(discovery_info)], True) + + +class EufyLight(Light): + """Representation of a Eufy light.""" + + def __init__(self, device): + """Initialize the light.""" + # pylint: disable=import-error + import lakeside + + self._temp = None + self._brightness = None + self._hs = None + self._state = None + self._name = device['name'] + self._address = device['address'] + self._code = device['code'] + self._type = device['type'] + self._bulb = lakeside.bulb(self._address, self._code, self._type) + self._colormode = False + if self._type == "T1011": + self._features = SUPPORT_BRIGHTNESS + elif self._type == "T1012": + self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + elif self._type == "T1013": + self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | \ + SUPPORT_COLOR + self._bulb.connect() + + def update(self): + """Synchronise state from the bulb.""" + self._bulb.update() + if self._bulb.power: + self._brightness = self._bulb.brightness + self._temp = self._bulb.temperature + if self._bulb.colors: + self._colormode = True + self._hs = color_util.color_RGB_to_hs(*self._bulb.colors) + else: + self._colormode = False + self._state = self._bulb.power + + @property + def unique_id(self): + """Return the ID of this light.""" + return self._address + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return int(self._brightness * 255 / 100) + + @property + def min_mireds(self): + """Return minimum supported color temperature.""" + return kelvin_to_mired(EUFY_MAX_KELVIN) + + @property + def max_mireds(self): + """Return maximu supported color temperature.""" + return kelvin_to_mired(EUFY_MIN_KELVIN) + + @property + def color_temp(self): + """Return the color temperature of this light.""" + temp_in_k = int(EUFY_MIN_KELVIN + (self._temp * + (EUFY_MAX_KELVIN - EUFY_MIN_KELVIN) + / 100)) + return kelvin_to_mired(temp_in_k) + + @property + def hs_color(self): + """Return the color of this light.""" + if not self._colormode: + return None + return self._hs + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + def turn_on(self, **kwargs): + """Turn the specified light on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + colortemp = kwargs.get(ATTR_COLOR_TEMP) + # pylint: disable=invalid-name + hs = kwargs.get(ATTR_HS_COLOR) + + if brightness is not None: + brightness = int(brightness * 100 / 255) + else: + if self._brightness is None: + self._brightness = 100 + brightness = self._brightness + + if colortemp is not None: + self._colormode = False + temp_in_k = mired_to_kelvin(colortemp) + relative_temp = temp_in_k - EUFY_MIN_KELVIN + temp = int(relative_temp * 100 / + (EUFY_MAX_KELVIN - EUFY_MIN_KELVIN)) + else: + temp = None + + if hs is not None: + rgb = color_util.color_hsv_to_RGB( + hs[0], hs[1], brightness / 255 * 100) + self._colormode = True + elif self._colormode: + rgb = color_util.color_hsv_to_RGB( + self._hs[0], self._hs[1], brightness / 255 * 100) + else: + rgb = None + + try: + self._bulb.set_state(power=True, brightness=brightness, + temperature=temp, colors=rgb) + except BrokenPipeError: + self._bulb.connect() + self._bulb.set_state(power=True, brightness=brightness, + temperature=temp, colors=rgb) + + def turn_off(self, **kwargs): + """Turn the specified light off.""" + try: + self._bulb.set_state(power=False) + except BrokenPipeError: + self._bulb.connect() + self._bulb.set_state(power=False) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index 2a239c9ae10..fc85e05238f 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -12,10 +12,11 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_PROTOCOL from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_EFFECT, EFFECT_COLORLOOP, - EFFECT_RANDOM, SUPPORT_BRIGHTNESS, SUPPORT_EFFECT, - SUPPORT_RGB_COLOR, Light, PLATFORM_SCHEMA) + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_EFFECT, ATTR_WHITE_VALUE, + EFFECT_COLORLOOP, EFFECT_RANDOM, SUPPORT_BRIGHTNESS, SUPPORT_EFFECT, + SUPPORT_COLOR, SUPPORT_WHITE_VALUE, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util REQUIREMENTS = ['flux_led==0.21'] @@ -27,7 +28,7 @@ ATTR_MODE = 'mode' DOMAIN = 'flux_led' SUPPORT_FLUX_LED = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | - SUPPORT_RGB_COLOR) + SUPPORT_COLOR) MODE_RGB = 'rgb' MODE_RGBW = 'rgbw' @@ -183,15 +184,23 @@ class FluxLight(Light): return self._bulb.brightness @property - def rgb_color(self): + def hs_color(self): """Return the color property.""" - return self._bulb.getRgb() + return color_util.color_RGB_to_hs(*self._bulb.getRgb()) @property def supported_features(self): """Flag supported features.""" + if self._mode is MODE_RGBW: + return SUPPORT_FLUX_LED | SUPPORT_WHITE_VALUE + return SUPPORT_FLUX_LED + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return self._bulb.getRgbw()[3] + @property def effect_list(self): """Return the list of supported effects.""" @@ -202,26 +211,46 @@ class FluxLight(Light): if not self.is_on: self._bulb.turnOn() - rgb = kwargs.get(ATTR_RGB_COLOR) + hs_color = kwargs.get(ATTR_HS_COLOR) + + if hs_color: + rgb = color_util.color_hs_to_RGB(*hs_color) + else: + rgb = None + brightness = kwargs.get(ATTR_BRIGHTNESS) effect = kwargs.get(ATTR_EFFECT) + white = kwargs.get(ATTR_WHITE_VALUE) - if rgb is not None and brightness is not None: - self._bulb.setRgb(*tuple(rgb), brightness=brightness) - elif rgb is not None: - self._bulb.setRgb(*tuple(rgb)) - elif brightness is not None: - if self._mode == MODE_RGBW: - self._bulb.setWarmWhite255(brightness) - elif self._mode == MODE_RGB: - (red, green, blue) = self._bulb.getRgb() - self._bulb.setRgb(red, green, blue, brightness=brightness) - elif effect == EFFECT_RANDOM: + # Show warning if effect set with rgb, brightness, or white level + if effect and (brightness or white or rgb): + _LOGGER.warning("RGB, brightness and white level are ignored when" + " an effect is specified for a flux bulb") + + # Random color effect + if effect == EFFECT_RANDOM: self._bulb.setRgb(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) + return + + # Effect selection elif effect in EFFECT_MAP: self._bulb.setPresetPattern(EFFECT_MAP[effect], 50) + return + + # Preserve current brightness on color/white level change + if brightness is None: + brightness = self.brightness + + # Preserve color on brightness/white level change + if rgb is None: + rgb = self._bulb.getRgb() + + self._bulb.setRgb(*tuple(rgb), brightness=brightness) + + if white is not None: + self._bulb.setWarmWhite255(white) def turn_off(self, **kwargs): """Turn the specified or all lights off.""" diff --git a/homeassistant/components/light/group.py b/homeassistant/components/light/group.py index b4a5e9dddfb..f9ffbb4e0bf 100644 --- a/homeassistant/components/light/group.py +++ b/homeassistant/components/light/group.py @@ -19,12 +19,11 @@ from homeassistant.const import (STATE_ON, ATTR_ENTITY_ID, CONF_NAME, from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.typing import HomeAssistantType, ConfigType from homeassistant.components.light import ( - SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_COLOR_TEMP, - SUPPORT_TRANSITION, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_XY_COLOR, - SUPPORT_WHITE_VALUE, PLATFORM_SCHEMA, ATTR_BRIGHTNESS, ATTR_XY_COLOR, - ATTR_RGB_COLOR, ATTR_WHITE_VALUE, ATTR_COLOR_TEMP, ATTR_MIN_MIREDS, - ATTR_MAX_MIREDS, ATTR_EFFECT_LIST, ATTR_EFFECT, ATTR_FLASH, - ATTR_TRANSITION) + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION, + SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_WHITE_VALUE, PLATFORM_SCHEMA, + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, ATTR_COLOR_TEMP, + ATTR_MIN_MIREDS, ATTR_MAX_MIREDS, ATTR_EFFECT_LIST, ATTR_EFFECT, + ATTR_FLASH, ATTR_TRANSITION) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -37,8 +36,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) SUPPORT_GROUP_LIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT - | SUPPORT_FLASH | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION - | SUPPORT_XY_COLOR | SUPPORT_WHITE_VALUE) + | SUPPORT_FLASH | SUPPORT_COLOR | SUPPORT_TRANSITION + | SUPPORT_WHITE_VALUE) async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, @@ -58,8 +57,7 @@ class LightGroup(light.Light): self._is_on = False # type: bool self._available = False # type: bool self._brightness = None # type: Optional[int] - self._xy_color = None # type: Optional[Tuple[float, float]] - self._rgb_color = None # type: Optional[Tuple[int, int, int]] + self._hs_color = None # type: Optional[Tuple[float, float]] self._color_temp = None # type: Optional[int] self._min_mireds = 154 # type: Optional[int] self._max_mireds = 500 # type: Optional[int] @@ -108,14 +106,9 @@ class LightGroup(light.Light): return self._brightness @property - def xy_color(self) -> Optional[Tuple[float, float]]: - """Return the XY color value [float, float].""" - return self._xy_color - - @property - def rgb_color(self) -> Optional[Tuple[int, int, int]]: - """Return the RGB color value [int, int, int].""" - return self._rgb_color + def hs_color(self) -> Optional[Tuple[float, float]]: + """Return the HS color value [float, float].""" + return self._hs_color @property def color_temp(self) -> Optional[int]: @@ -164,11 +157,8 @@ class LightGroup(light.Light): if ATTR_BRIGHTNESS in kwargs: data[ATTR_BRIGHTNESS] = kwargs[ATTR_BRIGHTNESS] - if ATTR_XY_COLOR in kwargs: - data[ATTR_XY_COLOR] = kwargs[ATTR_XY_COLOR] - - if ATTR_RGB_COLOR in kwargs: - data[ATTR_RGB_COLOR] = kwargs[ATTR_RGB_COLOR] + if ATTR_HS_COLOR in kwargs: + data[ATTR_HS_COLOR] = kwargs[ATTR_HS_COLOR] if ATTR_COLOR_TEMP in kwargs: data[ATTR_COLOR_TEMP] = kwargs[ATTR_COLOR_TEMP] @@ -210,13 +200,8 @@ class LightGroup(light.Light): self._brightness = _reduce_attribute(on_states, ATTR_BRIGHTNESS) - self._xy_color = _reduce_attribute( - on_states, ATTR_XY_COLOR, reduce=_mean_tuple) - - self._rgb_color = _reduce_attribute( - on_states, ATTR_RGB_COLOR, reduce=_mean_tuple) - if self._rgb_color is not None: - self._rgb_color = tuple(map(int, self._rgb_color)) + self._hs_color = _reduce_attribute( + on_states, ATTR_HS_COLOR, reduce=_mean_tuple) self._white_value = _reduce_attribute(on_states, ATTR_WHITE_VALUE) diff --git a/homeassistant/components/light/hive.py b/homeassistant/components/light/hive.py index e57bdf2c046..1fd9e8aaaca 100644 --- a/homeassistant/components/light/hive.py +++ b/homeassistant/components/light/hive.py @@ -4,13 +4,13 @@ Support for the Hive devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.hive/ """ -import colorsys from homeassistant.components.hive import DATA_HIVE from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, - ATTR_RGB_COLOR, + ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, - SUPPORT_RGB_COLOR, Light) + SUPPORT_COLOR, Light) +import homeassistant.util.color as color_util DEPENDENCIES = ['hive'] @@ -34,6 +34,7 @@ class HiveDeviceLight(Light): self.device_type = hivedevice["HA_DeviceType"] self.light_device_type = hivedevice["Hive_Light_DeviceType"] self.session = hivesession + self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) self.session.entities.append(self) @@ -48,6 +49,11 @@ class HiveDeviceLight(Light): """Return the display name of this light.""" return self.node_name + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self.attributes + @property def brightness(self): """Brightness of the light (an integer in the range 1-255).""" @@ -75,10 +81,11 @@ class HiveDeviceLight(Light): return self.session.light.get_color_temp(self.node_id) @property - def rgb_color(self) -> tuple: - """Return the RBG color value.""" + def hs_color(self) -> tuple: + """Return the hs color value.""" if self.light_device_type == "colourtuneablelight": - return self.session.light.get_color(self.node_id) + rgb = self.session.light.get_color(self.node_id) + return color_util.color_RGB_to_hs(*rgb) @property def is_on(self): @@ -99,15 +106,11 @@ class HiveDeviceLight(Light): if ATTR_COLOR_TEMP in kwargs: tmp_new_color_temp = kwargs.get(ATTR_COLOR_TEMP) new_color_temp = round(1000000 / tmp_new_color_temp) - if ATTR_RGB_COLOR in kwargs: - get_new_color = kwargs.get(ATTR_RGB_COLOR) - tmp_new_color = colorsys.rgb_to_hsv(get_new_color[0], - get_new_color[1], - get_new_color[2]) - hue = int(round(tmp_new_color[0] * 360)) - saturation = int(round(tmp_new_color[1] * 100)) - value = int(round((tmp_new_color[2] / 255) * 100)) - new_color = (hue, saturation, value) + if ATTR_HS_COLOR in kwargs: + get_new_color = kwargs.get(ATTR_HS_COLOR) + hue = int(get_new_color[0]) + saturation = int(get_new_color[1]) + new_color = (hue, saturation, 100) self.session.light.turn_on(self.node_id, self.light_device_type, new_brightness, new_color_temp, @@ -132,10 +135,12 @@ class HiveDeviceLight(Light): supported_features = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP) elif self.light_device_type == "colourtuneablelight": supported_features = ( - SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR) + SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR) return supported_features 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) diff --git a/homeassistant/components/light/homekit_controller.py b/homeassistant/components/light/homekit_controller.py new file mode 100644 index 00000000000..e6dc09e455c --- /dev/null +++ b/homeassistant/components/light/homekit_controller.py @@ -0,0 +1,134 @@ +""" +Support for Homekit lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.homekit_controller/ +""" +import json +import logging + +from homeassistant.components.homekit_controller import ( + HomeKitEntity, KNOWN_ACCESSORIES) +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_COLOR_TEMP, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light) + +DEPENDENCIES = ['homekit_controller'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Homekit lighting.""" + if discovery_info is not None: + accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + add_devices([HomeKitLight(accessory, discovery_info)], True) + + +class HomeKitLight(HomeKitEntity, Light): + """Representation of a Homekit light.""" + + def __init__(self, *args): + """Initialise the light.""" + super().__init__(*args) + self._on = None + self._brightness = None + self._color_temperature = None + self._hue = None + self._saturation = None + + def update_characteristics(self, characteristics): + """Synchronise light state with Home Assistant.""" + # pylint: disable=import-error + import homekit + + for characteristic in characteristics: + ctype = characteristic['type'] + ctype = homekit.CharacteristicsTypes.get_short(ctype) + if ctype == "on": + self._chars['on'] = characteristic['iid'] + self._on = characteristic['value'] + elif ctype == 'brightness': + self._chars['brightness'] = characteristic['iid'] + self._features |= SUPPORT_BRIGHTNESS + self._brightness = characteristic['value'] + elif ctype == 'color-temperature': + self._chars['color_temperature'] = characteristic['iid'] + self._features |= SUPPORT_COLOR_TEMP + self._color_temperature = characteristic['value'] + elif ctype == "hue": + self._chars['hue'] = characteristic['iid'] + self._features |= SUPPORT_COLOR + self._hue = characteristic['value'] + elif ctype == "saturation": + self._chars['saturation'] = characteristic['iid'] + self._features |= SUPPORT_COLOR + self._saturation = characteristic['value'] + + @property + def is_on(self): + """Return true if device is on.""" + return self._on + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + if self._features & SUPPORT_BRIGHTNESS: + return self._brightness * 255 / 100 + return None + + @property + def hs_color(self): + """Return the color property.""" + if self._features & SUPPORT_COLOR: + return (self._hue, self._saturation) + return None + + @property + def color_temp(self): + """Return the color temperature.""" + if self._features & SUPPORT_COLOR_TEMP: + return self._color_temperature + return None + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + def turn_on(self, **kwargs): + """Turn the specified light on.""" + hs_color = kwargs.get(ATTR_HS_COLOR) + temperature = kwargs.get(ATTR_COLOR_TEMP) + brightness = kwargs.get(ATTR_BRIGHTNESS) + + characteristics = [] + if hs_color is not None: + characteristics.append({'aid': self._aid, + 'iid': self._chars['hue'], + 'value': hs_color[0]}) + characteristics.append({'aid': self._aid, + 'iid': self._chars['saturation'], + 'value': hs_color[1]}) + if brightness is not None: + characteristics.append({'aid': self._aid, + 'iid': self._chars['brightness'], + 'value': int(brightness * 100 / 255)}) + + if temperature is not None: + characteristics.append({'aid': self._aid, + 'iid': self._chars['color-temperature'], + 'value': int(temperature)}) + characteristics.append({'aid': self._aid, + 'iid': self._chars['on'], + 'value': True}) + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) + + def turn_off(self, **kwargs): + """Turn the specified light off.""" + characteristics = [{'aid': self._aid, + 'iid': self._chars['on'], + 'value': False}] + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) diff --git a/homeassistant/components/light/homematicip_cloud.py b/homeassistant/components/light/homematicip_cloud.py new file mode 100644 index 00000000000..e433da44ae7 --- /dev/null +++ b/homeassistant/components/light/homematicip_cloud.py @@ -0,0 +1,76 @@ +""" +Support for HomematicIP light. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/light.homematicip_cloud/ +""" + +import logging + +from homeassistant.components.light import Light +from homeassistant.components.homematicip_cloud import ( + HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, + ATTR_HOME_ID) + +DEPENDENCIES = ['homematicip_cloud'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_POWER_CONSUMPTION = 'power_consumption' +ATTR_ENERGIE_COUNTER = 'energie_counter' +ATTR_PROFILE_MODE = 'profile_mode' + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the HomematicIP light devices.""" + from homematicip.device import ( + BrandSwitchMeasuring) + + 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, BrandSwitchMeasuring): + devices.append(HomematicipLightMeasuring(home, device)) + + if devices: + async_add_devices(devices) + + +class HomematicipLight(HomematicipGenericDevice, Light): + """MomematicIP light device.""" + + def __init__(self, home, device): + """Initialize the light device.""" + super().__init__(home, device) + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.on + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._device.turn_on() + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._device.turn_off() + + +class HomematicipLightMeasuring(HomematicipLight): + """MomematicIP measuring light device.""" + + @property + def current_power_w(self): + """Return the current power usage in W.""" + return self._device.currentPowerConsumption + + @property + def today_energy_kwh(self): + """Return the today total energy usage in kWh.""" + if self._device.energyCounter is None: + return 0 + return round(self._device.energyCounter) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 661b7c2b3a1..837a6f82510 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -8,36 +8,27 @@ import asyncio from datetime import timedelta import logging import random -import re -import socket -import voluptuous as vol +import async_timeout import homeassistant.components.hue as hue from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, - ATTR_TRANSITION, ATTR_XY_COLOR, EFFECT_COLORLOOP, EFFECT_RANDOM, - FLASH_LONG, FLASH_SHORT, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, - SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, - SUPPORT_TRANSITION, SUPPORT_XY_COLOR, Light) -from homeassistant.const import CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME -import homeassistant.helpers.config_validation as cv -import homeassistant.util as util -from homeassistant.util import yaml -import homeassistant.util.color as color_util + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, + ATTR_TRANSITION, ATTR_HS_COLOR, EFFECT_COLORLOOP, EFFECT_RANDOM, + FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_COLOR, SUPPORT_TRANSITION, + Light) +from homeassistant.util import color DEPENDENCIES = ['hue'] +SCAN_INTERVAL = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) - SUPPORT_HUE_ON_OFF = (SUPPORT_FLASH | SUPPORT_TRANSITION) SUPPORT_HUE_DIMMABLE = (SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS) SUPPORT_HUE_COLOR_TEMP = (SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP) -SUPPORT_HUE_COLOR = (SUPPORT_HUE_DIMMABLE | SUPPORT_EFFECT | - SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR) +SUPPORT_HUE_COLOR = (SUPPORT_HUE_DIMMABLE | SUPPORT_EFFECT | SUPPORT_COLOR) SUPPORT_HUE_EXTENDED = (SUPPORT_HUE_COLOR_TEMP | SUPPORT_HUE_COLOR) SUPPORT_HUE = { @@ -48,269 +39,268 @@ SUPPORT_HUE = { 'Color temperature light': SUPPORT_HUE_COLOR_TEMP } -ATTR_EMULATED_HUE_HIDDEN = 'emulated_hue_hidden' ATTR_IS_HUE_GROUP = 'is_hue_group' - -# Legacy configuration, will be removed in 0.60 -CONF_ALLOW_UNREACHABLE = 'allow_unreachable' -DEFAULT_ALLOW_UNREACHABLE = False -CONF_ALLOW_IN_EMULATED_HUE = 'allow_in_emulated_hue' -DEFAULT_ALLOW_IN_EMULATED_HUE = True -CONF_ALLOW_HUE_GROUPS = 'allow_hue_groups' -DEFAULT_ALLOW_HUE_GROUPS = True - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_ALLOW_UNREACHABLE): cv.boolean, - vol.Optional(CONF_FILENAME): cv.string, - vol.Optional(CONF_ALLOW_IN_EMULATED_HUE): cv.boolean, - vol.Optional(CONF_ALLOW_HUE_GROUPS, - default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, -}) - -MIGRATION_ID = 'light_hue_config_migration' -MIGRATION_TITLE = 'Philips Hue Configuration Migration' -MIGRATION_INSTRUCTIONS = """ -Configuration for the Philips Hue component has changed; action required. - -You have configured at least one bridge: - - hue: -{config} - -This configuration is deprecated, please check the -[Hue component](https://home-assistant.io/components/hue/) page for more -information. -""" - -SIGNAL_CALLBACK = 'hue_light_callback_{}_{}' +# Minimum Hue Bridge API version to support groups +# 1.4.0 introduced extended group info +# 1.12 introduced the state object for groups +# 1.13 introduced "any_on" to group state objects +GROUP_MIN_API_VERSION = (1, 13, 0) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Hue lights.""" - if discovery_info is None or 'bridge_id' not in discovery_info: - return +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Old way of setting up Hue lights. - if config is not None and config: - # Legacy configuration, will be removed in 0.60 - config_str = yaml.dump([config]) - # Indent so it renders in a fixed-width font - config_str = re.sub('(?m)^', ' ', config_str) - hass.components.persistent_notification.async_create( - MIGRATION_INSTRUCTIONS.format(config=config_str), - title=MIGRATION_TITLE, - notification_id=MIGRATION_ID) - - bridge_id = discovery_info['bridge_id'] - bridge = hass.data[hue.DOMAIN][bridge_id] - unthrottled_update_lights(hass, bridge, add_devices) + Can only be called when a user accidentally mentions hue platform in their + config. But even in that case it would have been ignored. + """ + pass -@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) -def update_lights(hass, bridge, add_devices): - """Update the Hue light objects with latest info from the bridge.""" - return unthrottled_update_lights(hass, bridge, add_devices) +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the Hue lights from a config entry.""" + bridge = hass.data[hue.DOMAIN][config_entry.data['host']] + cur_lights = {} + cur_groups = {} + + api_version = tuple( + int(v) for v in bridge.api.config.apiversion.split('.')) + + allow_groups = bridge.allow_groups + if allow_groups and api_version < GROUP_MIN_API_VERSION: + _LOGGER.warning('Please update your Hue bridge to support groups') + allow_groups = False + + # Hue updates all lights via a single API call. + # + # If we call a service to update 2 lights, we only want the API to be + # called once. + # + # The throttle decorator will return right away if a call is currently + # in progress. This means that if we are updating 2 lights, the first one + # is in the update method, the second one will skip it and assume the + # update went through and updates it's data, not good! + # + # The current mechanism will make sure that all lights will wait till + # the update call is done before writing their data to the state machine. + # + # An alternative approach would be to disable automatic polling by Home + # Assistant and take control ourselves. This works great for polling as now + # we trigger from 1 time update an update to all entities. However it gets + # tricky from inside async_turn_on and async_turn_off. + # + # If automatic polling is enabled, Home Assistant will call the entity + # update method after it is done calling all the services. This means that + # when we update, we know all commands have been processed. If we trigger + # the update from inside async_turn_on, the update will not capture the + # changes to the second entity until the next polling update because the + # throttle decorator will prevent the call. + + progress = None + light_progress = set() + group_progress = set() + + async def request_update(is_group, object_id): + """Request an update. + + We will only make 1 request to the server for updating at a time. If a + request is in progress, we will join the request that is in progress. + + This approach is possible because should_poll=True. That means that + Home Assistant will ask lights for updates during a polling cycle or + after it has called a service. + + We keep track of the lights that are waiting for the request to finish. + When new data comes in, we'll trigger an update for all non-waiting + lights. This covers the case where a service is called to enable 2 + lights but in the meanwhile some other light has changed too. + """ + nonlocal progress + + progress_set = group_progress if is_group else light_progress + progress_set.add(object_id) + + if progress is not None: + return await progress + + progress = asyncio.ensure_future(update_bridge()) + result = await progress + progress = None + light_progress.clear() + group_progress.clear() + return result + + async def update_bridge(): + """Update the values of the bridge. + + Will update lights and, if enabled, groups from the bridge. + """ + tasks = [] + tasks.append(async_update_items( + hass, bridge, async_add_devices, request_update, + False, cur_lights, light_progress + )) + + if allow_groups: + tasks.append(async_update_items( + hass, bridge, async_add_devices, request_update, + True, cur_groups, group_progress + )) + + await asyncio.wait(tasks) + + await update_bridge() -def unthrottled_update_lights(hass, bridge, add_devices): - """Update the lights (Internal version of update_lights).""" - import phue +async def async_update_items(hass, bridge, async_add_devices, + request_bridge_update, is_group, current, + progress_waiting): + """Update either groups or lights from the bridge.""" + import aiohue - if not bridge.configured: - return + if is_group: + api = bridge.api.groups + else: + api = bridge.api.lights try: - api = bridge.get_api() - except phue.PhueRequestTimeout: - _LOGGER.warning("Timeout trying to reach the bridge") - bridge.available = False - return - except ConnectionRefusedError: - _LOGGER.error("The bridge refused the connection") - bridge.available = False - return - except socket.error: - # socket.error when we cannot reach Hue - _LOGGER.exception("Cannot reach the bridge") + with async_timeout.timeout(4): + await api.update() + except (asyncio.TimeoutError, aiohue.AiohueException): + if not bridge.available: + return + + _LOGGER.error('Unable to reach bridge %s', bridge.host) bridge.available = False + + for light_id, light in current.items(): + if light_id not in progress_waiting: + light.async_schedule_update_ha_state() + return - bridge.available = True + if not bridge.available: + _LOGGER.info('Reconnected to bridge %s', bridge.host) + bridge.available = True - new_lights = process_lights( - hass, api, bridge, - lambda **kw: update_lights(hass, bridge, add_devices, **kw)) - if bridge.allow_hue_groups: - new_lightgroups = process_groups( - hass, api, bridge, - lambda **kw: update_lights(hass, bridge, add_devices, **kw)) - new_lights.extend(new_lightgroups) + new_lights = [] + + for item_id in api: + if item_id not in current: + current[item_id] = HueLight( + api[item_id], request_bridge_update, bridge, is_group) + + new_lights.append(current[item_id]) + elif item_id not in progress_waiting: + current[item_id].async_schedule_update_ha_state() if new_lights: - add_devices(new_lights) - - -def process_lights(hass, api, bridge, update_lights_cb): - """Set up HueLight objects for all lights.""" - api_lights = api.get('lights') - - if not isinstance(api_lights, dict): - _LOGGER.error("Got unexpected result from Hue API") - return [] - - new_lights = [] - - for light_id, info in api_lights.items(): - if light_id not in bridge.lights: - bridge.lights[light_id] = HueLight( - int(light_id), info, bridge, - update_lights_cb, - bridge.allow_unreachable, - bridge.allow_in_emulated_hue) - new_lights.append(bridge.lights[light_id]) - else: - bridge.lights[light_id].info = info - hass.helpers.dispatcher.dispatcher_send( - SIGNAL_CALLBACK.format( - bridge.bridge_id, - bridge.lights[light_id].light_id)) - - return new_lights - - -def process_groups(hass, api, bridge, update_lights_cb): - """Set up HueLight objects for all groups.""" - api_groups = api.get('groups') - - if not isinstance(api_groups, dict): - _LOGGER.error('Got unexpected result from Hue API') - return [] - - new_lights = [] - - for lightgroup_id, info in api_groups.items(): - if 'state' not in info: - _LOGGER.warning( - "Group info does not contain state. Please update your hub") - return [] - - if lightgroup_id not in bridge.lightgroups: - bridge.lightgroups[lightgroup_id] = HueLight( - int(lightgroup_id), info, bridge, - update_lights_cb, - bridge.allow_unreachable, - bridge.allow_in_emulated_hue, True) - new_lights.append(bridge.lightgroups[lightgroup_id]) - else: - bridge.lightgroups[lightgroup_id].info = info - hass.helpers.dispatcher.dispatcher_send( - SIGNAL_CALLBACK.format( - bridge.bridge_id, - bridge.lightgroups[lightgroup_id].light_id)) - - return new_lights + async_add_devices(new_lights) class HueLight(Light): """Representation of a Hue light.""" - def __init__(self, light_id, info, bridge, update_lights_cb, - allow_unreachable, allow_in_emulated_hue, is_group=False): + def __init__(self, light, request_bridge_update, bridge, is_group=False): """Initialize the light.""" - self.light_id = light_id - self.info = info + self.light = light + self.async_request_bridge_update = request_bridge_update self.bridge = bridge - self.update_lights = update_lights_cb - self.allow_unreachable = allow_unreachable self.is_group = is_group - self.allow_in_emulated_hue = allow_in_emulated_hue if is_group: - self._command_func = self.bridge.set_group + self.is_osram = False + self.is_philips = False else: - self._command_func = self.bridge.set_light + self.is_osram = light.manufacturername == 'OSRAM' + self.is_philips = light.manufacturername == 'Philips' @property def unique_id(self): """Return the ID of this Hue light.""" - return self.info.get('uniqueid') + return self.light.uniqueid @property def name(self): """Return the name of the Hue light.""" - return self.info.get('name', DEVICE_DEFAULT_NAME) + return self.light.name @property def brightness(self): """Return the brightness of this light between 0..255.""" if self.is_group: - return self.info['action'].get('bri') - return self.info['state'].get('bri') + return self.light.action.get('bri') + return self.light.state.get('bri') @property - def xy_color(self): - """Return the XY color value.""" + def _color_mode(self): + """Return the hue color mode.""" if self.is_group: - return self.info['action'].get('xy') - return self.info['state'].get('xy') + return self.light.action.get('colormode') + return self.light.state.get('colormode') + + @property + def hs_color(self): + """Return the hs color value.""" + mode = self._color_mode + source = self.light.action if self.is_group else self.light.state + + if mode in ('xy', 'hs') and 'xy' in source: + return color.color_xy_to_hs(*source['xy']) + + return None @property def color_temp(self): """Return the CT color value.""" + # Don't return color temperature unless in color temperature mode + if self._color_mode != "ct": + return None + if self.is_group: - return self.info['action'].get('ct') - return self.info['state'].get('ct') + return self.light.action.get('ct') + return self.light.state.get('ct') @property def is_on(self): """Return true if device is on.""" if self.is_group: - return self.info['state']['any_on'] - return self.info['state']['on'] + return self.light.state['any_on'] + return self.light.state['on'] @property def available(self): """Return if light is available.""" return self.bridge.available and (self.is_group or - self.allow_unreachable or - self.info['state']['reachable']) + self.bridge.allow_unreachable or + self.light.state['reachable']) @property def supported_features(self): """Flag supported features.""" - return SUPPORT_HUE.get(self.info.get('type'), SUPPORT_HUE_EXTENDED) + return SUPPORT_HUE.get(self.light.type, SUPPORT_HUE_EXTENDED) @property def effect_list(self): """Return the list of supported effects.""" return [EFFECT_COLORLOOP, EFFECT_RANDOM] - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the specified or all lights on.""" command = {'on': True} if ATTR_TRANSITION in kwargs: command['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10) - if ATTR_XY_COLOR in kwargs: - if self.info.get('manufacturername') == 'OSRAM': - color_hue, sat = color_util.color_xy_to_hs( - *kwargs[ATTR_XY_COLOR]) - command['hue'] = color_hue / 360 * 65535 - command['sat'] = sat / 100 * 255 + if ATTR_HS_COLOR in kwargs: + if self.is_osram: + command['hue'] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535) + command['sat'] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) else: - command['xy'] = kwargs[ATTR_XY_COLOR] - elif ATTR_RGB_COLOR in kwargs: - if self.info.get('manufacturername') == 'OSRAM': - hsv = color_util.color_RGB_to_hsv( - *(int(val) for val in kwargs[ATTR_RGB_COLOR])) - command['hue'] = hsv[0] / 360 * 65535 - command['sat'] = hsv[1] / 100 * 255 - command['bri'] = hsv[2] / 100 * 255 - else: - xyb = color_util.color_RGB_to_xy( - *(int(val) for val in kwargs[ATTR_RGB_COLOR])) - command['xy'] = xyb[0], xyb[1] + # Philips hue bulb models respond differently to hue/sat + # requests, so we convert to XY first to ensure a consistent + # color. + command['xy'] = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) elif ATTR_COLOR_TEMP in kwargs: temp = kwargs[ATTR_COLOR_TEMP] command['ct'] = max(self.min_mireds, min(temp, self.max_mireds)) @@ -336,12 +326,15 @@ class HueLight(Light): elif effect == EFFECT_RANDOM: command['hue'] = random.randrange(0, 65535) command['sat'] = random.randrange(150, 254) - elif self.info.get('manufacturername') == 'Philips': + elif self.is_philips: command['effect'] = 'none' - self._command_func(self.light_id, command) + if self.is_group: + await self.light.set_action(**command) + else: + await self.light.set_state(**command) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the specified or all lights off.""" command = {'on': False} @@ -359,27 +352,19 @@ class HueLight(Light): else: command['alert'] = 'none' - self._command_func(self.light_id, command) + if self.is_group: + await self.light.set_action(**command) + else: + await self.light.set_state(**command) - def update(self): + async def async_update(self): """Synchronize state with bridge.""" - self.update_lights(no_throttle=True) + await self.async_request_bridge_update(self.is_group, self.light.id) @property def device_state_attributes(self): """Return the device state attributes.""" attributes = {} - if not self.allow_in_emulated_hue: - attributes[ATTR_EMULATED_HUE_HIDDEN] = \ - not self.allow_in_emulated_hue if self.is_group: attributes[ATTR_IS_HUE_GROUP] = self.is_group return attributes - - @asyncio.coroutine - def async_added_to_hass(self): - """Register update callback.""" - dev_id = self.bridge.bridge_id, self.light_id - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_CALLBACK.format(*dev_id), - self.async_schedule_update_ha_state) diff --git a/homeassistant/components/light/hyperion.py b/homeassistant/components/light/hyperion.py index 2057192299e..8ba2329af7e 100644 --- a/homeassistant/components/light/hyperion.py +++ b/homeassistant/components/light/hyperion.py @@ -11,10 +11,11 @@ import socket import voluptuous as vol from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_EFFECT, SUPPORT_BRIGHTNESS, - SUPPORT_RGB_COLOR, SUPPORT_EFFECT, Light, PLATFORM_SCHEMA) + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_EFFECT, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, SUPPORT_EFFECT, Light, PLATFORM_SCHEMA) from homeassistant.const import (CONF_HOST, CONF_PORT, CONF_NAME) import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -40,7 +41,7 @@ DEFAULT_EFFECT_LIST = ['HDMI', 'Cinema brighten lights', 'Cinema dim lights', 'Color traces', 'UDP multicast listener', 'UDP listener', 'X-Mas'] -SUPPORT_HYPERION = (SUPPORT_RGB_COLOR | SUPPORT_BRIGHTNESS | SUPPORT_EFFECT) +SUPPORT_HYPERION = (SUPPORT_COLOR | SUPPORT_BRIGHTNESS | SUPPORT_EFFECT) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -107,9 +108,9 @@ class Hyperion(Light): return self._brightness @property - def rgb_color(self): - """Return last RGB color value set.""" - return self._rgb_color + def hs_color(self): + """Return last color value set.""" + return color_util.color_RGB_to_hs(*self._rgb_color) @property def is_on(self): @@ -138,8 +139,8 @@ class Hyperion(Light): def turn_on(self, **kwargs): """Turn the lights on.""" - if ATTR_RGB_COLOR in kwargs: - rgb_color = kwargs[ATTR_RGB_COLOR] + if ATTR_HS_COLOR in kwargs: + rgb_color = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) elif self._rgb_mem == [0, 0, 0]: rgb_color = self._default_color else: @@ -214,7 +215,7 @@ class Hyperion(Light): pass led_color = response['info']['activeLedColor'] - if not led_color or led_color[0]['RGB value'] == [0, 0, 0]: + if not led_color or led_color[0]['RGB Value'] == [0, 0, 0]: # Get the active effect if response['info'].get('activeEffects'): self._rgb_color = [175, 0, 255] @@ -233,8 +234,7 @@ class Hyperion(Light): self._effect = None else: # Get the RGB color - self._rgb_color =\ - response['info']['activeLedColor'][0]['RGB Value'] + self._rgb_color = led_color[0]['RGB Value'] self._brightness = max(self._rgb_color) self._rgb_mem = [int(round(float(x)*255/self._brightness)) for x in self._rgb_color] diff --git a/homeassistant/components/light/iglo.py b/homeassistant/components/light/iglo.py index c7de8d8bede..f40dc2ce84e 100644 --- a/homeassistant/components/light/iglo.py +++ b/homeassistant/components/light/iglo.py @@ -10,8 +10,8 @@ import math import voluptuous as vol from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_RGB_COLOR, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, SUPPORT_EFFECT, + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, SUPPORT_EFFECT, PLATFORM_SCHEMA, Light) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT import homeassistant.helpers.config_validation as cv @@ -77,9 +77,9 @@ class IGloLamp(Light): self._lamp.min_kelvin)) @property - def rgb_color(self): - """Return the RGB value.""" - return self._lamp.state()['rgb'] + def hs_color(self): + """Return the hs value.""" + return color_util.color_RGB_to_hs(*self._lamp.state()['rgb']) @property def effect(self): @@ -95,7 +95,7 @@ class IGloLamp(Light): def supported_features(self): """Flag supported features.""" return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | - SUPPORT_RGB_COLOR | SUPPORT_EFFECT) + SUPPORT_COLOR | SUPPORT_EFFECT) @property def is_on(self): @@ -111,8 +111,8 @@ class IGloLamp(Light): self._lamp.brightness(brightness) return - if ATTR_RGB_COLOR in kwargs: - rgb = kwargs[ATTR_RGB_COLOR] + if ATTR_HS_COLOR in kwargs: + rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) self._lamp.rgb(*rgb) return diff --git a/homeassistant/components/light/insteon_plm.py b/homeassistant/components/light/insteon_plm.py index 40453da38e5..8a3b463c2bd 100644 --- a/homeassistant/components/light/insteon_plm.py +++ b/homeassistant/components/light/insteon_plm.py @@ -21,7 +21,7 @@ MAX_BRIGHTNESS = 255 @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Insteon PLM device.""" - plm = hass.data['insteon_plm'] + plm = hass.data['insteon_plm'].get('plm') address = discovery_info['address'] device = plm.devices[address] diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 83083e34bad..18446951735 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -9,11 +9,12 @@ import voluptuous as vol from homeassistant.components.knx import ATTR_DISCOVER_DEVICES, DATA_KNX from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, - SUPPORT_RGB_COLOR, Light) + ATTR_BRIGHTNESS, ATTR_HS_COLOR, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, Light) from homeassistant.const import CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util CONF_ADDRESS = 'address' CONF_STATE_ADDRESS = 'state_address' @@ -114,15 +115,10 @@ class KNXLight(Light): None @property - def xy_color(self): - """Return the XY color value [float, float].""" - return None - - @property - def rgb_color(self): - """Return the RBG color value.""" + def hs_color(self): + """Return the HS color value.""" if self.device.supports_color: - return self.device.current_color + return color_util.color_RGB_to_hs(*self.device.current_color) return None @property @@ -157,7 +153,7 @@ class KNXLight(Light): if self.device.supports_brightness: flags |= SUPPORT_BRIGHTNESS if self.device.supports_color: - flags |= SUPPORT_RGB_COLOR + flags |= SUPPORT_COLOR return flags async def async_turn_on(self, **kwargs): @@ -165,9 +161,10 @@ class KNXLight(Light): if ATTR_BRIGHTNESS in kwargs: if self.device.supports_brightness: await self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) - elif ATTR_RGB_COLOR in kwargs: + elif ATTR_HS_COLOR in kwargs: if self.device.supports_color: - await self.device.set_color(kwargs[ATTR_RGB_COLOR]) + await self.device.set_color(color_util.color_hs_to_RGB( + *kwargs[ATTR_HS_COLOR])) else: await self.device.set_on() diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 0bb65a78c6e..dff5ccd42ac 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -16,10 +16,10 @@ import voluptuous as vol from homeassistant import util from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, ATTR_COLOR_TEMP, - ATTR_EFFECT, ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, - DOMAIN, LIGHT_TURN_ON_SCHEMA, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, - SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, - SUPPORT_XY_COLOR, VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, Light, + ATTR_EFFECT, ATTR_HS_COLOR, ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION, + ATTR_XY_COLOR, COLOR_GROUP, DOMAIN, LIGHT_TURN_ON_SCHEMA, PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, + SUPPORT_TRANSITION, VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, Light, preprocess_turn_on_alternatives) from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback @@ -87,11 +87,22 @@ LIFX_EFFECT_SCHEMA = vol.Schema({ LIFX_EFFECT_PULSE_SCHEMA = LIFX_EFFECT_SCHEMA.extend({ ATTR_BRIGHTNESS: VALID_BRIGHTNESS, ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT, - ATTR_COLOR_NAME: cv.string, - ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), - vol.Coerce(tuple)), - ATTR_COLOR_TEMP: vol.All(vol.Coerce(int), vol.Range(min=1)), - ATTR_KELVIN: vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, + vol.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): + vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), + vol.Coerce(tuple)), + vol.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): + vol.All(vol.ExactSequence((cv.small_float, cv.small_float)), + vol.Coerce(tuple)), + vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): + vol.All(vol.ExactSequence( + (vol.All(vol.Coerce(float), vol.Range(min=0, max=360)), + vol.All(vol.Coerce(float), vol.Range(min=0, max=100)))), + vol.Coerce(tuple)), + vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Exclusive(ATTR_KELVIN, COLOR_GROUP): + vol.All(vol.Coerce(int), vol.Range(min=0)), ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Range(min=0.05)), ATTR_CYCLES: vol.All(vol.Coerce(float), vol.Range(min=1)), ATTR_MODE: vol.In(PULSE_MODES), @@ -168,16 +179,8 @@ def find_hsbk(**kwargs): preprocess_turn_on_alternatives(kwargs) - if ATTR_RGB_COLOR in kwargs: - hue, saturation, brightness = \ - color_util.color_RGB_to_hsv(*kwargs[ATTR_RGB_COLOR]) - hue = int(hue / 360 * 65535) - saturation = int(saturation / 100 * 65535) - brightness = int(brightness / 100 * 65535) - kelvin = 3500 - - if ATTR_XY_COLOR in kwargs: - hue, saturation = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) + if ATTR_HS_COLOR in kwargs: + hue, saturation = kwargs[ATTR_HS_COLOR] hue = int(hue / 360 * 65535) saturation = int(saturation / 100 * 65535) kelvin = 3500 @@ -585,7 +588,7 @@ class LIFXColor(LIFXLight): def supported_features(self): """Flag supported features.""" support = super().supported_features - support |= SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR + support |= SUPPORT_COLOR return support @property @@ -598,15 +601,12 @@ class LIFXColor(LIFXLight): ] @property - def rgb_color(self): - """Return the RGB value.""" - hue, sat, bri, _ = self.device.color - + def hs_color(self): + """Return the hs value.""" + hue, sat, _, _ = self.device.color hue = hue / 65535 * 360 sat = sat / 65535 * 100 - bri = bri / 65535 * 100 - - return color_util.color_hsv_to_RGB(hue, sat, bri) + return (hue, sat) class LIFXStrip(LIFXColor): diff --git a/homeassistant/components/light/lifx_legacy.py b/homeassistant/components/light/lifx_legacy.py index cf3dba848a8..490eeb6ecab 100644 --- a/homeassistant/components/light/lifx_legacy.py +++ b/homeassistant/components/light/lifx_legacy.py @@ -7,14 +7,13 @@ not yet support Windows. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.lifx/ """ -import colorsys import logging import voluptuous as vol from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA) from homeassistant.helpers.event import track_time_change from homeassistant.util.color import ( @@ -37,7 +36,7 @@ TEMP_MAX_HASS = 500 TEMP_MIN = 2500 TEMP_MIN_HASS = 154 -SUPPORT_LIFX = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR | +SUPPORT_LIFX = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR | SUPPORT_TRANSITION) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -129,17 +128,6 @@ class LIFX(object): self._liffylights.probe(address) -def convert_rgb_to_hsv(rgb): - """Convert Home Assistant RGB values to HSV values.""" - red, green, blue = [_ / BYTE_MAX for _ in rgb] - - hue, saturation, brightness = colorsys.rgb_to_hsv(red, green, blue) - - return [int(hue * SHORT_MAX), - int(saturation * SHORT_MAX), - int(brightness * SHORT_MAX)] - - class LIFXLight(Light): """Representation of a LIFX light.""" @@ -170,11 +158,9 @@ class LIFXLight(Light): return self._ip @property - def rgb_color(self): - """Return the RGB value.""" - _LOGGER.debug( - "rgb_color: [%d %d %d]", self._rgb[0], self._rgb[1], self._rgb[2]) - return self._rgb + def hs_color(self): + """Return the hs value.""" + return (self._hue / 65535 * 360, self._sat / 65535 * 100) @property def brightness(self): @@ -209,13 +195,13 @@ class LIFXLight(Light): else: fade = 0 - if ATTR_RGB_COLOR in kwargs: - hue, saturation, brightness = \ - convert_rgb_to_hsv(kwargs[ATTR_RGB_COLOR]) + if ATTR_HS_COLOR in kwargs: + hue, saturation = kwargs[ATTR_HS_COLOR] + hue = hue / 360 * 65535 + saturation = saturation / 100 * 65535 else: hue = self._hue saturation = self._sat - brightness = self._bri if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] * (BYTE_MAX + 1) @@ -265,16 +251,3 @@ class LIFXLight(Light): self._sat = sat self._bri = bri self._kel = kel - - red, green, blue = colorsys.hsv_to_rgb(hue / SHORT_MAX, - sat / SHORT_MAX, - bri / SHORT_MAX) - - red = int(red * BYTE_MAX) - green = int(green * BYTE_MAX) - blue = int(blue * BYTE_MAX) - - _LOGGER.debug("set_color: %d %d %d %d [%d %d %d]", - hue, sat, bri, kel, red, green, blue) - - self._rgb = [red, green, blue] diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index f011792a15c..bd4fece89e3 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -12,12 +12,13 @@ import voluptuous as vol from homeassistant.const import ( CONF_NAME, CONF_HOST, CONF_PORT, CONF_TYPE, STATE_ON) from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, EFFECT_COLORLOOP, EFFECT_WHITE, FLASH_LONG, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, - SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA) + SUPPORT_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -from homeassistant.util.color import color_temperature_mired_to_kelvin +from homeassistant.util.color import ( + color_temperature_mired_to_kelvin, color_hs_to_RGB) from homeassistant.helpers.restore_state import async_get_last_state REQUIREMENTS = ['limitlessled==1.1.0'] @@ -40,19 +41,19 @@ LED_TYPE = ['rgbw', 'rgbww', 'white', 'bridge-led', 'dimmer'] EFFECT_NIGHT = 'night' -RGB_BOUNDARY = 40 +MIN_SATURATION = 10 -WHITE = [255, 255, 255] +WHITE = [0, 0] SUPPORT_LIMITLESSLED_WHITE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION) SUPPORT_LIMITLESSLED_DIMMER = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION) SUPPORT_LIMITLESSLED_RGB = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | - SUPPORT_FLASH | SUPPORT_RGB_COLOR | + SUPPORT_FLASH | SUPPORT_COLOR | SUPPORT_TRANSITION) SUPPORT_LIMITLESSLED_RGBWW = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | SUPPORT_FLASH | - SUPPORT_RGB_COLOR | SUPPORT_TRANSITION) + SUPPORT_COLOR | SUPPORT_TRANSITION) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_BRIDGES): vol.All(cv.ensure_list, [ @@ -141,10 +142,9 @@ def state(new_state): from limitlessled.pipeline import Pipeline pipeline = Pipeline() transition_time = DEFAULT_TRANSITION - # Stop any repeating pipeline. - if self.repeating: - self.repeating = False + if self._effect == EFFECT_COLORLOOP: self.group.stop() + self._effect = None # Set transition time. if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) @@ -182,11 +182,11 @@ class LimitlessLEDGroup(Light): self.group = group self.config = config - self.repeating = False self._is_on = False self._brightness = None self._temperature = None self._color = None + self._effect = None @asyncio.coroutine def async_added_to_hass(self): @@ -196,7 +196,7 @@ class LimitlessLEDGroup(Light): self._is_on = (last_state.state == STATE_ON) self._brightness = last_state.attributes.get('brightness') self._temperature = last_state.attributes.get('color_temp') - self._color = last_state.attributes.get('rgb_color') + self._color = last_state.attributes.get('hs_color') @property def should_poll(self): @@ -221,6 +221,9 @@ class LimitlessLEDGroup(Light): @property def brightness(self): """Return the brightness property.""" + if self._effect == EFFECT_NIGHT: + return 1 + return self._brightness @property @@ -239,8 +242,11 @@ class LimitlessLEDGroup(Light): return self._temperature @property - def rgb_color(self): + def hs_color(self): """Return the color property.""" + if self._effect == EFFECT_NIGHT: + return None + return self._color @property @@ -248,6 +254,11 @@ class LimitlessLEDGroup(Light): """Flag supported features.""" return self._supported + @property + def effect(self): + """Return the current effect for this light.""" + return self._effect + @property def effect_list(self): """Return the list of supported effects for this light.""" @@ -269,6 +280,7 @@ class LimitlessLEDGroup(Light): if kwargs.get(ATTR_EFFECT) == EFFECT_NIGHT: if EFFECT_NIGHT in self._effect_list: pipeline.night_light() + self._effect = EFFECT_NIGHT return pipeline.on() @@ -282,17 +294,17 @@ class LimitlessLEDGroup(Light): self._brightness = kwargs[ATTR_BRIGHTNESS] args['brightness'] = self.limitlessled_brightness() - if ATTR_RGB_COLOR in kwargs and self._supported & SUPPORT_RGB_COLOR: - self._color = kwargs[ATTR_RGB_COLOR] + if ATTR_HS_COLOR in kwargs and self._supported & SUPPORT_COLOR: + self._color = kwargs[ATTR_HS_COLOR] # White is a special case. - if min(self._color) > 256 - RGB_BOUNDARY: + if self._color[1] < MIN_SATURATION: pipeline.white() self._color = WHITE else: args['color'] = self.limitlessled_color() if ATTR_COLOR_TEMP in kwargs: - if self._supported & SUPPORT_RGB_COLOR: + if self._supported & SUPPORT_COLOR: pipeline.white() self._color = WHITE if self._supported & SUPPORT_COLOR_TEMP: @@ -313,7 +325,7 @@ class LimitlessLEDGroup(Light): if ATTR_EFFECT in kwargs and self._effect_list: if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: from limitlessled.presets import COLORLOOP - self.repeating = True + self._effect = EFFECT_COLORLOOP pipeline.append(COLORLOOP) if kwargs[ATTR_EFFECT] == EFFECT_WHITE: pipeline.white() @@ -333,6 +345,6 @@ class LimitlessLEDGroup(Light): return self._brightness / 255 def limitlessled_color(self): - """Convert Home Assistant RGB list to Color tuple.""" + """Convert Home Assistant HS list to RGB Color tuple.""" from limitlessled import Color - return Color(*tuple(self._color)) + return Color(*color_hs_to_RGB(*tuple(self._color))) diff --git a/homeassistant/components/light/lw12wifi.py b/homeassistant/components/light/lw12wifi.py new file mode 100644 index 00000000000..f81d8368f98 --- /dev/null +++ b/homeassistant/components/light/lw12wifi.py @@ -0,0 +1,158 @@ +""" +Support for Lagute LW-12 WiFi LED Controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.lw12wifi/ +""" + +import logging + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, + Light, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_EFFECT, + SUPPORT_COLOR, SUPPORT_TRANSITION +) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PORT +) +import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util + + +REQUIREMENTS = ['lw12==0.9.2'] + +_LOGGER = logging.getLogger(__name__) + + +DEFAULT_NAME = 'LW-12 FC' +DEFAULT_PORT = 5000 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup LW-12 WiFi LED Controller platform.""" + import lw12 + + # Assign configuration variables. + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + # Add devices + lw12_light = lw12.LW12Controller(host, port) + add_devices([LW12WiFi(name, lw12_light)]) + + +class LW12WiFi(Light): + """LW-12 WiFi LED Controller.""" + + def __init__(self, name, lw12_light): + """Initialisation of LW-12 WiFi LED Controller. + + Args: + name: Friendly name for this platform to use. + lw12_light: Instance of the LW12 controller. + """ + self._light = lw12_light + self._name = name + self._state = None + self._effect = None + self._rgb_color = [255, 255, 255] + self._brightness = 255 + # Setup feature list + self._supported_features = SUPPORT_BRIGHTNESS | SUPPORT_EFFECT \ + | SUPPORT_COLOR | SUPPORT_TRANSITION + + @property + def name(self): + """Return the display name of the controlled light.""" + return self._name + + @property + def brightness(self): + """Return the brightness of the light.""" + return self._brightness + + @property + def hs_color(self): + """Read back the hue-saturation of the light.""" + return color_util.color_RGB_to_hs(*self._rgb_color) + + @property + def effect(self): + """Return current light effect.""" + if self._effect is None: + return None + return self._effect.replace('_', ' ').title() + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + @property + def supported_features(self): + """Return a list of supported features.""" + return self._supported_features + + @property + def effect_list(self): + """Return a list of available effects. + + Use the Enum element name for display. + """ + import lw12 + return [effect.name.replace('_', ' ').title() + for effect in lw12.LW12_EFFECT] + + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return True + + @property + def shoud_poll(self) -> bool: + """Return False to not poll the state of this entity.""" + return False + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + import lw12 + self._light.light_on() + if ATTR_HS_COLOR in kwargs: + self._rgb_color = color_util.color_hs_to_RGB( + *kwargs[ATTR_HS_COLOR]) + self._light.set_color(*self._rgb_color) + self._effect = None + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs.get(ATTR_BRIGHTNESS) + brightness = int(self._brightness / 255 * 100) + self._light.set_light_option(lw12.LW12_LIGHT.BRIGHTNESS, + brightness) + if ATTR_EFFECT in kwargs: + self._effect = kwargs[ATTR_EFFECT].replace(' ', '_').upper() + # Check if a known and supported effect was selected. + if self._effect in [eff.name for eff in lw12.LW12_EFFECT]: + # Selected effect is supported and will be applied. + self._light.set_effect(lw12.LW12_EFFECT[self._effect]) + else: + # Unknown effect was set, recover by disabling the effect + # mode and log an error. + _LOGGER.error("Unknown effect selected: %s", self._effect) + self._effect = None + if ATTR_TRANSITION in kwargs: + transition_speed = int(kwargs[ATTR_TRANSITION]) + self._light.set_light_option(lw12.LW12_LIGHT.FLASH, + transition_speed) + self._state = True + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self._light.light_off() + self._state = False diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index f97e37127b1..97a4cc8c137 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -4,7 +4,6 @@ Support for MQTT lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.mqtt/ """ -import asyncio import logging import voluptuous as vol @@ -12,19 +11,20 @@ import voluptuous as vol from homeassistant.core import callback import homeassistant.components.mqtt as mqtt from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_RGB_COLOR, - ATTR_WHITE_VALUE, ATTR_XY_COLOR, Light, SUPPORT_BRIGHTNESS, - SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_RGB_COLOR, - SUPPORT_WHITE_VALUE, SUPPORT_XY_COLOR) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, + ATTR_WHITE_VALUE, Light, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, SUPPORT_COLOR, SUPPORT_WHITE_VALUE) from homeassistant.const import ( CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_EFFECT, CONF_NAME, - CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, + CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, STATE_ON, CONF_RGB, CONF_STATE, CONF_VALUE_TEMPLATE, CONF_WHITE_VALUE, CONF_XY) from homeassistant.components.mqtt import ( CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, MqttAvailability) +from homeassistant.helpers.restore_state import async_get_last_state import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -100,8 +100,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -@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 a MQTT Light.""" if discovery_info is not None: config = PLATFORM_SCHEMA(discovery_info) @@ -191,14 +191,13 @@ class MqttLight(MqttAvailability, Light): self._on_command_type = on_command_type self._state = False self._brightness = None - self._rgb = None + self._hs = None self._color_temp = None self._effect = None self._white_value = None - self._xy = None self._supported_features = 0 self._supported_features |= ( - topic[CONF_RGB_COMMAND_TOPIC] is not None and SUPPORT_RGB_COLOR) + topic[CONF_RGB_COMMAND_TOPIC] is not None and SUPPORT_COLOR) self._supported_features |= ( topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None and SUPPORT_BRIGHTNESS) @@ -212,12 +211,11 @@ class MqttLight(MqttAvailability, Light): topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None and SUPPORT_WHITE_VALUE) self._supported_features |= ( - topic[CONF_XY_COMMAND_TOPIC] is not None and SUPPORT_XY_COLOR) + topic[CONF_XY_COMMAND_TOPIC] is not None and SUPPORT_COLOR) - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe to MQTT events.""" - yield from super().async_added_to_hass() + await super().async_added_to_hass() templates = {} for key, tpl in list(self._templates.items()): @@ -227,6 +225,8 @@ class MqttLight(MqttAvailability, Light): tpl.hass = self.hass templates[key] = tpl.async_render_with_possible_json_value + last_state = await async_get_last_state(self.hass, self.entity_id) + @callback def state_received(topic, payload, qos): """Handle new MQTT messages.""" @@ -238,9 +238,11 @@ class MqttLight(MqttAvailability, Light): self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_STATE_TOPIC], state_received, self._qos) + elif self._optimistic and last_state: + self._state = last_state.state == STATE_ON @callback def brightness_received(topic, payload, qos): @@ -251,10 +253,13 @@ class MqttLight(MqttAvailability, Light): self.async_schedule_update_ha_state() if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_BRIGHTNESS_STATE_TOPIC], brightness_received, self._qos) self._brightness = 255 + elif self._optimistic_brightness and last_state\ + and last_state.attributes.get(ATTR_BRIGHTNESS): + self._brightness = last_state.attributes.get(ATTR_BRIGHTNESS) elif self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None: self._brightness = 255 else: @@ -263,19 +268,21 @@ class MqttLight(MqttAvailability, Light): @callback def rgb_received(topic, payload, qos): """Handle new MQTT messages for RGB.""" - self._rgb = [int(val) for val in - templates[CONF_RGB](payload).split(',')] + rgb = [int(val) for val in + templates[CONF_RGB](payload).split(',')] + self._hs = color_util.color_RGB_to_hs(*rgb) self.async_schedule_update_ha_state() if self._topic[CONF_RGB_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_RGB_STATE_TOPIC], rgb_received, self._qos) - self._rgb = [255, 255, 255] - if self._topic[CONF_RGB_COMMAND_TOPIC] is not None: - self._rgb = [255, 255, 255] - else: - self._rgb = None + self._hs = (0, 0) + if self._optimistic_rgb and last_state\ + and last_state.attributes.get(ATTR_HS_COLOR): + self._hs = last_state.attributes.get(ATTR_HS_COLOR) + elif self._topic[CONF_RGB_COMMAND_TOPIC] is not None: + self._hs = (0, 0) @callback def color_temp_received(topic, payload, qos): @@ -284,11 +291,14 @@ class MqttLight(MqttAvailability, Light): self.async_schedule_update_ha_state() if self._topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_COLOR_TEMP_STATE_TOPIC], color_temp_received, self._qos) self._color_temp = 150 - if self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None: + if self._optimistic_color_temp and last_state\ + and last_state.attributes.get(ATTR_COLOR_TEMP): + self._color_temp = last_state.attributes.get(ATTR_COLOR_TEMP) + elif self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None: self._color_temp = 150 else: self._color_temp = None @@ -300,11 +310,14 @@ class MqttLight(MqttAvailability, Light): self.async_schedule_update_ha_state() if self._topic[CONF_EFFECT_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_EFFECT_STATE_TOPIC], effect_received, self._qos) self._effect = 'none' - if self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None: + if self._optimistic_effect and last_state\ + and last_state.attributes.get(ATTR_EFFECT): + self._effect = last_state.attributes.get(ATTR_EFFECT) + elif self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None: self._effect = 'none' else: self._effect = None @@ -318,10 +331,13 @@ class MqttLight(MqttAvailability, Light): self.async_schedule_update_ha_state() if self._topic[CONF_WHITE_VALUE_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_WHITE_VALUE_STATE_TOPIC], white_value_received, self._qos) self._white_value = 255 + elif self._optimistic_white_value and last_state\ + and last_state.attributes.get(ATTR_WHITE_VALUE): + self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE) elif self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None: self._white_value = 255 else: @@ -330,19 +346,21 @@ class MqttLight(MqttAvailability, Light): @callback def xy_received(topic, payload, qos): """Handle new MQTT messages for color.""" - self._xy = [float(val) for val in + xy_color = [float(val) for val in templates[CONF_XY](payload).split(',')] + self._hs = color_util.color_xy_to_hs(*xy_color) self.async_schedule_update_ha_state() if self._topic[CONF_XY_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_XY_STATE_TOPIC], xy_received, self._qos) - self._xy = [1, 1] - if self._topic[CONF_XY_COMMAND_TOPIC] is not None: - self._xy = [1, 1] - else: - self._xy = None + self._hs = (0, 0) + if self._optimistic_xy and last_state\ + and last_state.attributes.get(ATTR_HS_COLOR): + self._hs = last_state.attributes.get(ATTR_HS_COLOR) + elif self._topic[CONF_XY_COMMAND_TOPIC] is not None: + self._hs = (0, 0) @property def brightness(self): @@ -350,9 +368,9 @@ class MqttLight(MqttAvailability, Light): return self._brightness @property - def rgb_color(self): - """Return the RGB color value.""" - return self._rgb + def hs_color(self): + """Return the hs color value.""" + return self._hs @property def color_temp(self): @@ -364,11 +382,6 @@ class MqttLight(MqttAvailability, Light): """Return the white property.""" return self._white_value - @property - def xy_color(self): - """Return the RGB color value.""" - return self._xy - @property def should_poll(self): """No polling needed for a MQTT light.""" @@ -404,8 +417,7 @@ class MqttLight(MqttAvailability, Light): """Flag supported features.""" return self._supported_features - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on. This method is a coroutine. @@ -426,24 +438,43 @@ class MqttLight(MqttAvailability, Light): kwargs[ATTR_BRIGHTNESS] = self._brightness if \ self._brightness else 255 - if ATTR_RGB_COLOR in kwargs and \ + if ATTR_HS_COLOR in kwargs and \ self._topic[CONF_RGB_COMMAND_TOPIC] is not None: + hs_color = kwargs[ATTR_HS_COLOR] + brightness = kwargs.get( + ATTR_BRIGHTNESS, self._brightness if self._brightness else 255) + rgb = color_util.color_hsv_to_RGB( + hs_color[0], hs_color[1], brightness / 255 * 100) tpl = self._templates[CONF_RGB_COMMAND_TEMPLATE] if tpl: - colors = ('red', 'green', 'blue') - variables = {key: val for key, val in - zip(colors, kwargs[ATTR_RGB_COLOR])} - rgb_color_str = tpl.async_render(variables) + rgb_color_str = tpl.async_render({ + 'red': rgb[0], + 'green': rgb[1], + 'blue': rgb[2], + }) else: - rgb_color_str = '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]) + rgb_color_str = '{},{},{}'.format(*rgb) mqtt.async_publish( self.hass, self._topic[CONF_RGB_COMMAND_TOPIC], rgb_color_str, self._qos, self._retain) if self._optimistic_rgb: - self._rgb = kwargs[ATTR_RGB_COLOR] + self._hs = kwargs[ATTR_HS_COLOR] + should_update = True + + if ATTR_HS_COLOR in kwargs and \ + self._topic[CONF_XY_COMMAND_TOPIC] is not None: + + xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) + mqtt.async_publish( + self.hass, self._topic[CONF_XY_COMMAND_TOPIC], + '{},{}'.format(*xy_color), self._qos, + self._retain) + + if self._optimistic_xy: + self._hs = kwargs[ATTR_HS_COLOR] should_update = True if ATTR_BRIGHTNESS in kwargs and \ @@ -493,18 +524,6 @@ class MqttLight(MqttAvailability, Light): self._white_value = kwargs[ATTR_WHITE_VALUE] should_update = True - if ATTR_XY_COLOR in kwargs and \ - self._topic[CONF_XY_COMMAND_TOPIC] is not None: - - mqtt.async_publish( - self.hass, self._topic[CONF_XY_COMMAND_TOPIC], - '{},{}'.format(*kwargs[ATTR_XY_COLOR]), self._qos, - self._retain) - - if self._optimistic_xy: - self._xy = kwargs[ATTR_XY_COLOR] - should_update = True - if self._on_command_type == 'last': mqtt.async_publish(self.hass, self._topic[CONF_COMMAND_TOPIC], self._payload['on'], self._qos, self._retain) @@ -518,8 +537,7 @@ class MqttLight(MqttAvailability, Light): if should_update: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off. This method is a coroutine. diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py index 19747b89ca0..14f5ee7a9b9 100644 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -4,7 +4,6 @@ Support for MQTT JSON lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.mqtt_json/ """ -import asyncio import logging import json import voluptuous as vol @@ -13,19 +12,22 @@ from homeassistant.core import callback import homeassistant.components.mqtt as mqtt from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, - ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, ATTR_XY_COLOR, + ATTR_TRANSITION, ATTR_WHITE_VALUE, ATTR_HS_COLOR, FLASH_LONG, FLASH_SHORT, Light, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, - SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, - SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, SUPPORT_XY_COLOR) + SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_COLOR, + SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE) from homeassistant.components.light.mqtt import CONF_BRIGHTNESS_SCALE from homeassistant.const import ( - CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_EFFECT, + CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_EFFECT, STATE_ON, CONF_NAME, CONF_OPTIMISTIC, CONF_RGB, CONF_WHITE_VALUE, CONF_XY) from homeassistant.components.mqtt import ( CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, MqttAvailability) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.helpers.restore_state import async_get_last_state +import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -43,12 +45,14 @@ DEFAULT_OPTIMISTIC = False DEFAULT_RGB = False DEFAULT_WHITE_VALUE = False DEFAULT_XY = False +DEFAULT_HS = False DEFAULT_BRIGHTNESS_SCALE = 255 CONF_EFFECT_LIST = 'effect_list' CONF_FLASH_TIME_LONG = 'flash_time_long' CONF_FLASH_TIME_SHORT = 'flash_time_short' +CONF_HS = 'hs' # Stealing some of these from the base MQTT configs. PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -71,12 +75,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_WHITE_VALUE, default=DEFAULT_WHITE_VALUE): cv.boolean, vol.Optional(CONF_XY, default=DEFAULT_XY): cv.boolean, + vol.Optional(CONF_HS, default=DEFAULT_HS): cv.boolean, vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up a MQTT JSON Light.""" if discovery_info is not None: config = PLATFORM_SCHEMA(discovery_info) @@ -98,6 +103,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_RGB), config.get(CONF_WHITE_VALUE), config.get(CONF_XY), + config.get(CONF_HS), { key: config.get(key) for key in ( CONF_FLASH_TIME_SHORT, @@ -115,7 +121,7 @@ class MqttJson(MqttAvailability, Light): """Representation of a MQTT JSON light.""" def __init__(self, name, effect_list, topic, qos, retain, optimistic, - brightness, color_temp, effect, rgb, white_value, xy, + brightness, color_temp, effect, rgb, white_value, xy, hs, flash_times, availability_topic, payload_available, payload_not_available, brightness_scale): """Initialize MQTT JSON light.""" @@ -128,6 +134,9 @@ class MqttJson(MqttAvailability, Light): self._retain = retain self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None self._state = False + self._rgb = rgb + self._xy = xy + self._hs_support = hs if brightness: self._brightness = 255 else: @@ -143,36 +152,33 @@ class MqttJson(MqttAvailability, Light): else: self._effect = None - if rgb: - self._rgb = [0, 0, 0] + if hs or rgb or xy: + self._hs = [0, 0] else: - self._rgb = None + self._hs = None if white_value: self._white_value = 255 else: self._white_value = None - if xy: - self._xy = [1, 1] - else: - self._xy = None - self._flash_times = flash_times self._brightness_scale = brightness_scale self._supported_features = (SUPPORT_TRANSITION | SUPPORT_FLASH) - self._supported_features |= (rgb and SUPPORT_RGB_COLOR) + self._supported_features |= (rgb and SUPPORT_COLOR) self._supported_features |= (brightness and SUPPORT_BRIGHTNESS) self._supported_features |= (color_temp and SUPPORT_COLOR_TEMP) self._supported_features |= (effect and SUPPORT_EFFECT) self._supported_features |= (white_value and SUPPORT_WHITE_VALUE) - self._supported_features |= (xy and SUPPORT_XY_COLOR) + self._supported_features |= (xy and SUPPORT_COLOR) + self._supported_features |= (hs and SUPPORT_COLOR) - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe to MQTT events.""" - yield from super().async_added_to_hass() + await super().async_added_to_hass() + + last_state = await async_get_last_state(self.hass, self.entity_id) @callback def state_received(topic, payload, qos): @@ -184,18 +190,38 @@ class MqttJson(MqttAvailability, Light): elif values['state'] == 'OFF': self._state = False - if self._rgb is not None: + if self._hs is not None: try: red = int(values['color']['r']) green = int(values['color']['g']) blue = int(values['color']['b']) - self._rgb = [red, green, blue] + self._hs = color_util.color_RGB_to_hs(red, green, blue) except KeyError: pass except ValueError: _LOGGER.warning("Invalid RGB color value received") + try: + x_color = float(values['color']['x']) + y_color = float(values['color']['y']) + + self._hs = color_util.color_xy_to_hs(x_color, y_color) + except KeyError: + pass + except ValueError: + _LOGGER.warning("Invalid XY color value received") + + try: + hue = float(values['color']['h']) + saturation = float(values['color']['s']) + + self._hs = (hue, saturation) + except KeyError: + pass + except ValueError: + _LOGGER.warning("Invalid HS color value received") + if self._brightness is not None: try: self._brightness = int(values['brightness'] / @@ -230,24 +256,26 @@ class MqttJson(MqttAvailability, Light): except ValueError: _LOGGER.warning("Invalid white value received") - if self._xy is not None: - try: - x_color = float(values['color']['x']) - y_color = float(values['color']['y']) - - self._xy = [x_color, y_color] - except KeyError: - pass - except ValueError: - _LOGGER.warning("Invalid XY color value received") - self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_STATE_TOPIC], state_received, self._qos) + if self._optimistic and last_state: + self._state = last_state.state == STATE_ON + if last_state.attributes.get(ATTR_BRIGHTNESS): + self._brightness = last_state.attributes.get(ATTR_BRIGHTNESS) + if last_state.attributes.get(ATTR_HS_COLOR): + self._hs = last_state.attributes.get(ATTR_HS_COLOR) + if last_state.attributes.get(ATTR_COLOR_TEMP): + self._color_temp = last_state.attributes.get(ATTR_COLOR_TEMP) + if last_state.attributes.get(ATTR_EFFECT): + self._effect = last_state.attributes.get(ATTR_EFFECT) + if last_state.attributes.get(ATTR_WHITE_VALUE): + self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE) + @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -269,20 +297,15 @@ class MqttJson(MqttAvailability, Light): return self._effect_list @property - def rgb_color(self): - """Return the RGB color value.""" - return self._rgb + def hs_color(self): + """Return the hs color value.""" + return self._hs @property def white_value(self): """Return the white property.""" return self._white_value - @property - def xy_color(self): - """Return the XY color value.""" - return self._xy - @property def should_poll(self): """No polling needed for a MQTT light.""" @@ -308,8 +331,7 @@ class MqttJson(MqttAvailability, Light): """Flag supported features.""" return self._supported_features - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on. This method is a coroutine. @@ -318,15 +340,29 @@ class MqttJson(MqttAvailability, Light): message = {'state': 'ON'} - if ATTR_RGB_COLOR in kwargs: - message['color'] = { - 'r': kwargs[ATTR_RGB_COLOR][0], - 'g': kwargs[ATTR_RGB_COLOR][1], - 'b': kwargs[ATTR_RGB_COLOR][2] - } + if ATTR_HS_COLOR in kwargs and (self._hs_support + or self._rgb or self._xy): + hs_color = kwargs[ATTR_HS_COLOR] + message['color'] = {} + if self._rgb: + brightness = kwargs.get( + ATTR_BRIGHTNESS, + self._brightness if self._brightness else 255) + rgb = color_util.color_hsv_to_RGB( + hs_color[0], hs_color[1], brightness / 255 * 100) + message['color']['r'] = rgb[0] + message['color']['g'] = rgb[1] + message['color']['b'] = rgb[2] + if self._xy: + xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) + message['color']['x'] = xy_color[0] + message['color']['y'] = xy_color[1] + if self._hs_support: + message['color']['h'] = hs_color[0] + message['color']['s'] = hs_color[1] if self._optimistic: - self._rgb = kwargs[ATTR_RGB_COLOR] + self._hs = kwargs[ATTR_HS_COLOR] should_update = True if ATTR_FLASH in kwargs: @@ -370,16 +406,6 @@ class MqttJson(MqttAvailability, Light): self._white_value = kwargs[ATTR_WHITE_VALUE] should_update = True - if ATTR_XY_COLOR in kwargs: - message['color'] = { - 'x': kwargs[ATTR_XY_COLOR][0], - 'y': kwargs[ATTR_XY_COLOR][1] - } - - if self._optimistic: - self._xy = kwargs[ATTR_XY_COLOR] - should_update = True - mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], json.dumps(message), self._qos, self._retain) @@ -392,8 +418,7 @@ class MqttJson(MqttAvailability, Light): if should_update: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off. This method is a coroutine. diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt_template.py index de0f6d934c6..e32c13fc5b6 100644 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt_template.py @@ -4,7 +4,6 @@ Support for MQTT Template lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.mqtt_template/ """ -import asyncio import logging import voluptuous as vol @@ -12,15 +11,17 @@ from homeassistant.core import callback import homeassistant.components.mqtt as mqtt from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, - ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, Light, PLATFORM_SCHEMA, + ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, Light, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, - SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE) + SUPPORT_COLOR, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE) from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, STATE_ON, STATE_OFF from homeassistant.components.mqtt import ( CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, MqttAvailability) import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util +from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) @@ -65,8 +66,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -@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 a MQTT Template light.""" if discovery_info is not None: config = PLATFORM_SCHEMA(discovery_info) @@ -142,19 +143,20 @@ class MqttTemplate(MqttAvailability, Light): if (self._templates[CONF_RED_TEMPLATE] is not None and self._templates[CONF_GREEN_TEMPLATE] is not None and self._templates[CONF_BLUE_TEMPLATE] is not None): - self._rgb = [0, 0, 0] + self._hs = [0, 0] else: - self._rgb = None + self._hs = None self._effect = None for tpl in self._templates.values(): if tpl is not None: tpl.hass = hass - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe to MQTT events.""" - yield from super().async_added_to_hass() + await super().async_added_to_hass() + + last_state = await async_get_last_state(self.hass, self.entity_id) @callback def state_received(topic, payload, qos): @@ -186,17 +188,18 @@ class MqttTemplate(MqttAvailability, Light): except ValueError: _LOGGER.warning("Invalid color temperature value received") - if self._rgb is not None: + if self._hs is not None: try: - self._rgb[0] = int( + red = int( self._templates[CONF_RED_TEMPLATE]. async_render_with_possible_json_value(payload)) - self._rgb[1] = int( + green = int( self._templates[CONF_GREEN_TEMPLATE]. async_render_with_possible_json_value(payload)) - self._rgb[2] = int( + blue = int( self._templates[CONF_BLUE_TEMPLATE]. async_render_with_possible_json_value(payload)) + self._hs = color_util.color_RGB_to_hs(red, green, blue) except ValueError: _LOGGER.warning("Invalid color value received") @@ -221,10 +224,23 @@ class MqttTemplate(MqttAvailability, Light): self.async_schedule_update_ha_state() if self._topics[CONF_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topics[CONF_STATE_TOPIC], state_received, self._qos) + if self._optimistic and last_state: + self._state = last_state.state == STATE_ON + if last_state.attributes.get(ATTR_BRIGHTNESS): + self._brightness = last_state.attributes.get(ATTR_BRIGHTNESS) + if last_state.attributes.get(ATTR_HS_COLOR): + self._hs = last_state.attributes.get(ATTR_HS_COLOR) + if last_state.attributes.get(ATTR_COLOR_TEMP): + self._color_temp = last_state.attributes.get(ATTR_COLOR_TEMP) + if last_state.attributes.get(ATTR_EFFECT): + self._effect = last_state.attributes.get(ATTR_EFFECT) + if last_state.attributes.get(ATTR_WHITE_VALUE): + self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE) + @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -236,9 +252,9 @@ class MqttTemplate(MqttAvailability, Light): return self._color_temp @property - def rgb_color(self): - """Return the RGB color value [int, int, int].""" - return self._rgb + def hs_color(self): + """Return the hs color value [int, int].""" + return self._hs @property def white_value(self): @@ -278,8 +294,7 @@ class MqttTemplate(MqttAvailability, Light): """Return the current effect.""" return self._effect - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the entity on. This method is a coroutine. @@ -300,13 +315,18 @@ class MqttTemplate(MqttAvailability, Light): if self._optimistic: self._color_temp = kwargs[ATTR_COLOR_TEMP] - if ATTR_RGB_COLOR in kwargs: - values['red'] = kwargs[ATTR_RGB_COLOR][0] - values['green'] = kwargs[ATTR_RGB_COLOR][1] - values['blue'] = kwargs[ATTR_RGB_COLOR][2] + if ATTR_HS_COLOR in kwargs: + hs_color = kwargs[ATTR_HS_COLOR] + brightness = kwargs.get( + ATTR_BRIGHTNESS, self._brightness if self._brightness else 255) + rgb = color_util.color_hsv_to_RGB( + hs_color[0], hs_color[1], brightness / 255 * 100) + values['red'] = rgb[0] + values['green'] = rgb[1] + values['blue'] = rgb[2] if self._optimistic: - self._rgb = kwargs[ATTR_RGB_COLOR] + self._hs = kwargs[ATTR_HS_COLOR] if ATTR_WHITE_VALUE in kwargs: values['white_value'] = int(kwargs[ATTR_WHITE_VALUE]) @@ -332,8 +352,7 @@ class MqttTemplate(MqttAvailability, Light): if self._optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the entity off. This method is a coroutine. @@ -360,8 +379,8 @@ class MqttTemplate(MqttAvailability, Light): features = (SUPPORT_FLASH | SUPPORT_TRANSITION) if self._brightness is not None: features = features | SUPPORT_BRIGHTNESS - if self._rgb is not None: - features = features | SUPPORT_RGB_COLOR + if self._hs is not None: + features = features | SUPPORT_COLOR if self._effect_list is not None: features = features | SUPPORT_EFFECT if self._color_temp is not None: diff --git a/homeassistant/components/light/mysensors.py b/homeassistant/components/light/mysensors.py index a37553017e7..55387288d7f 100644 --- a/homeassistant/components/light/mysensors.py +++ b/homeassistant/components/light/mysensors.py @@ -6,17 +6,18 @@ https://home-assistant.io/components/light.mysensors/ """ from homeassistant.components import mysensors from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_WHITE_VALUE, DOMAIN, - SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_WHITE_VALUE, Light) + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, DOMAIN, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_WHITE_VALUE, Light) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.util.color import rgb_hex_to_rgb_list +import homeassistant.util.color as color_util -SUPPORT_MYSENSORS = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR | - SUPPORT_WHITE_VALUE) +SUPPORT_MYSENSORS_RGBW = SUPPORT_COLOR | SUPPORT_WHITE_VALUE -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for lights.""" +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the mysensors platform for lights.""" device_class_map = { 'S_DIMMER': MySensorsLightDimmer, 'S_RGB_LIGHT': MySensorsLightRGB, @@ -24,7 +25,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): } mysensors.setup_mysensors_platform( hass, DOMAIN, discovery_info, device_class_map, - add_devices=add_devices) + async_add_devices=async_add_devices) class MySensorsLight(mysensors.MySensorsEntity, Light): @@ -35,7 +36,7 @@ class MySensorsLight(mysensors.MySensorsEntity, Light): super().__init__(*args) self._state = None self._brightness = None - self._rgb = None + self._hs = None self._white = None @property @@ -44,9 +45,9 @@ class MySensorsLight(mysensors.MySensorsEntity, Light): return self._brightness @property - def rgb_color(self): - """Return the RGB color value [int, int, int].""" - return self._rgb + def hs_color(self): + """Return the hs color value [int, int].""" + return self._hs @property def white_value(self): @@ -63,11 +64,6 @@ class MySensorsLight(mysensors.MySensorsEntity, Light): """Return true if device is on.""" return self._state - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_MYSENSORS - def _turn_on_light(self): """Turn on light child device.""" set_req = self.gateway.const.SetReq @@ -103,10 +99,14 @@ class MySensorsLight(mysensors.MySensorsEntity, Light): def _turn_on_rgb_and_w(self, hex_template, **kwargs): """Turn on RGB or RGBW child device.""" - rgb = self._rgb + rgb = list(color_util.color_hs_to_RGB(*self._hs)) white = self._white hex_color = self._values.get(self.value_type) - new_rgb = kwargs.get(ATTR_RGB_COLOR) + hs_color = kwargs.get(ATTR_HS_COLOR) + if hs_color is not None: + new_rgb = color_util.color_hs_to_RGB(*hs_color) + else: + new_rgb = None new_white = kwargs.get(ATTR_WHITE_VALUE) if new_rgb is None and new_white is None: @@ -126,11 +126,11 @@ class MySensorsLight(mysensors.MySensorsEntity, Light): if self.gateway.optimistic: # optimistically assume that light has changed state - self._rgb = rgb + self._hs = color_util.color_RGB_to_hs(*rgb) self._white = white self._values[self.value_type] = hex_color - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" value_type = self.gateway.const.SetReq.V_LIGHT self.gateway.set_child_value( @@ -139,14 +139,14 @@ class MySensorsLight(mysensors.MySensorsEntity, Light): # optimistically assume that light has changed state self._state = False self._values[value_type] = STATE_OFF - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def _update_light(self): + def _async_update_light(self): """Update the controller with values from light child.""" value_type = self.gateway.const.SetReq.V_LIGHT self._state = self._values[value_type] == STATE_ON - def _update_dimmer(self): + def _async_update_dimmer(self): """Update the controller with values from dimmer child.""" value_type = self.gateway.const.SetReq.V_DIMMER if value_type in self._values: @@ -154,49 +154,62 @@ class MySensorsLight(mysensors.MySensorsEntity, Light): if self._brightness == 0: self._state = False - def _update_rgb_or_w(self): + def _async_update_rgb_or_w(self): """Update the controller with values from RGB or RGBW child.""" value = self._values[self.value_type] color_list = rgb_hex_to_rgb_list(value) if len(color_list) > 3: self._white = color_list.pop() - self._rgb = color_list + self._hs = color_util.color_RGB_to_hs(*color_list) class MySensorsLightDimmer(MySensorsLight): """Dimmer child class to MySensorsLight.""" - def turn_on(self, **kwargs): + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + async def async_turn_on(self, **kwargs): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) if self.gateway.optimistic: - 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() - self._update_light() - self._update_dimmer() + await super().async_update() + self._async_update_light() + self._async_update_dimmer() class MySensorsLightRGB(MySensorsLight): """RGB child class to MySensorsLight.""" - def turn_on(self, **kwargs): + @property + def supported_features(self): + """Flag supported features.""" + set_req = self.gateway.const.SetReq + if set_req.V_DIMMER in self._values: + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR + return SUPPORT_COLOR + + async def async_turn_on(self, **kwargs): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w('%02x%02x%02x', **kwargs) if self.gateway.optimistic: - 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() - self._update_light() - self._update_dimmer() - self._update_rgb_or_w() + await super().async_update() + self._async_update_light() + self._async_update_dimmer() + self._async_update_rgb_or_w() class MySensorsLightRGBW(MySensorsLightRGB): @@ -204,10 +217,18 @@ class MySensorsLightRGBW(MySensorsLightRGB): # pylint: disable=too-many-ancestors - def turn_on(self, **kwargs): + @property + def supported_features(self): + """Flag supported features.""" + set_req = self.gateway.const.SetReq + if set_req.V_DIMMER in self._values: + return SUPPORT_BRIGHTNESS | SUPPORT_MYSENSORS_RGBW + return SUPPORT_MYSENSORS_RGBW + + async def async_turn_on(self, **kwargs): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w('%02x%02x%02x%02x', **kwargs) if self.gateway.optimistic: - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/mystrom.py b/homeassistant/components/light/mystrom.py index ecb120e3079..8d7fb807c6d 100644 --- a/homeassistant/components/light/mystrom.py +++ b/homeassistant/components/light/mystrom.py @@ -11,16 +11,20 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.light import ( Light, PLATFORM_SCHEMA, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, - SUPPORT_EFFECT, ATTR_EFFECT, SUPPORT_FLASH) + SUPPORT_EFFECT, ATTR_EFFECT, SUPPORT_FLASH, SUPPORT_COLOR, + ATTR_HS_COLOR) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, STATE_UNKNOWN -REQUIREMENTS = ['python-mystrom==0.3.8'] +REQUIREMENTS = ['python-mystrom==0.4.2'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'myStrom bulb' -SUPPORT_MYSTROM = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | SUPPORT_FLASH) +SUPPORT_MYSTROM = ( + SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | SUPPORT_FLASH | + SUPPORT_COLOR +) EFFECT_RAINBOW = 'rainbow' EFFECT_SUNRISE = 'sunrise' @@ -39,7 +43,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the myStrom Light platform.""" - from pymystrom import MyStromBulb + from pymystrom.bulb import MyStromBulb from pymystrom.exceptions import MyStromConnectionError host = config.get(CONF_HOST) @@ -67,6 +71,8 @@ class MyStromLight(Light): self._state = None self._available = False self._brightness = 0 + self._color_h = 0 + self._color_s = 0 @property def name(self): @@ -83,6 +89,11 @@ class MyStromLight(Light): """Return the brightness of the light.""" return self._brightness + @property + def hs_color(self): + """Return the color of the light.""" + return self._color_h, self._color_s + @property def available(self) -> bool: """Return True if entity is available.""" @@ -105,11 +116,21 @@ class MyStromLight(Light): brightness = kwargs.get(ATTR_BRIGHTNESS, 255) effect = kwargs.get(ATTR_EFFECT) + if ATTR_HS_COLOR in kwargs: + color_h, color_s = kwargs[ATTR_HS_COLOR] + elif ATTR_BRIGHTNESS in kwargs: + # Brightness update, keep color + color_h, color_s = self._color_h, self._color_s + else: + color_h, color_s = 0, 0 # Back to white + try: if not self.is_on: self._bulb.set_on() if brightness is not None: - self._bulb.set_color_hsv(0, 0, round(brightness * 100 / 255)) + self._bulb.set_color_hsv( + int(color_h), int(color_s), round(brightness * 100 / 255) + ) if effect == EFFECT_SUNRISE: self._bulb.set_sunrise(30) if effect == EFFECT_RAINBOW: @@ -132,7 +153,14 @@ class MyStromLight(Light): try: self._state = self._bulb.get_status() - self._brightness = int(self._bulb.get_brightness()) * 255 / 100 + + colors = self._bulb.get_color()['color'] + color_h, color_s, color_v = colors.split(';') + + self._color_h = int(color_h) + self._color_s = int(color_s) + self._brightness = int(color_v) * 255 / 100 + self._available = True except MyStromConnectionError: _LOGGER.warning("myStrom bulb not online") diff --git a/homeassistant/components/light/nanoleaf_aurora.py b/homeassistant/components/light/nanoleaf_aurora.py new file mode 100644 index 00000000000..6a0d3c36e9f --- /dev/null +++ b/homeassistant/components/light/nanoleaf_aurora.py @@ -0,0 +1,194 @@ +""" +Support for Nanoleaf Aurora platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.nanoleaf_aurora/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, + PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, Light) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN +import homeassistant.helpers.config_validation as cv +from homeassistant.util import color as color_util +from homeassistant.util.color import \ + color_temperature_mired_to_kelvin as mired_to_kelvin +from homeassistant.util.json import load_json, save_json + +REQUIREMENTS = ['nanoleaf==0.4.1'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Aurora' + +DATA_NANOLEAF_AURORA = 'nanoleaf_aurora' + +CONFIG_FILE = '.nanoleaf_aurora.conf' + +ICON = 'mdi:triangle-outline' + +SUPPORT_AURORA = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | + SUPPORT_COLOR) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Nanoleaf Aurora device.""" + import nanoleaf + import nanoleaf.setup + if DATA_NANOLEAF_AURORA not in hass.data: + hass.data[DATA_NANOLEAF_AURORA] = dict() + + token = '' + if discovery_info is not None: + host = discovery_info['host'] + name = discovery_info['hostname'] + # if device already exists via config, skip discovery setup + if host in hass.data[DATA_NANOLEAF_AURORA]: + return + _LOGGER.info("Discovered a new Aurora: %s", discovery_info) + conf = load_json(hass.config.path(CONFIG_FILE)) + if conf.get(host, {}).get('token'): + token = conf[host]['token'] + else: + host = config[CONF_HOST] + name = config[CONF_NAME] + token = config[CONF_TOKEN] + + if not token: + token = nanoleaf.setup.generate_auth_token(host) + if not token: + _LOGGER.error("Could not generate the auth token, did you press " + "and hold the power button on %s" + "for 5-7 seconds?", name) + return + conf = load_json(hass.config.path(CONFIG_FILE)) + conf[host] = {'token': token} + save_json(hass.config.path(CONFIG_FILE), conf) + + aurora_light = nanoleaf.Aurora(host, token) + + if aurora_light.on is None: + _LOGGER.error( + "Could not connect to Nanoleaf Aurora: %s on %s", name, host) + return + + hass.data[DATA_NANOLEAF_AURORA][host] = aurora_light + add_devices([AuroraLight(aurora_light, name)], True) + + +class AuroraLight(Light): + """Representation of a Nanoleaf Aurora.""" + + def __init__(self, light, name): + """Initialize an Aurora light.""" + self._brightness = None + self._color_temp = None + self._effect = None + self._effects_list = None + self._light = light + self._name = name + self._hs_color = None + self._state = None + + @property + def brightness(self): + """Return the brightness of the light.""" + if self._brightness is not None: + return int(self._brightness * 2.55) + return None + + @property + def color_temp(self): + """Return the current color temperature.""" + if self._color_temp is not None: + return color_util.color_temperature_kelvin_to_mired( + self._color_temp) + return None + + @property + def effect(self): + """Return the current effect.""" + return self._effect + + @property + def effect_list(self): + """Return the list of supported effects.""" + return self._effects_list + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 154 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 833 + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + @property + def hs_color(self): + """Return the color in HS.""" + return self._hs_color + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_AURORA + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + self._light.on = True + brightness = kwargs.get(ATTR_BRIGHTNESS) + hs_color = kwargs.get(ATTR_HS_COLOR) + color_temp_mired = kwargs.get(ATTR_COLOR_TEMP) + effect = kwargs.get(ATTR_EFFECT) + + if hs_color: + hue, saturation = hs_color + self._light.hue = int(hue) + self._light.saturation = int(saturation) + + if color_temp_mired: + self._light.color_temperature = mired_to_kelvin(color_temp_mired) + if brightness: + self._light.brightness = int(brightness / 2.55) + if effect: + self._light.effect = effect + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self._light.on = False + + def update(self): + """Fetch new state data for this light.""" + self._brightness = self._light.brightness + self._color_temp = self._light.color_temperature + self._effect = self._light.effect + self._effects_list = self._light.effects_list + self._hs_color = self._light.hue, self._light.saturation + self._state = self._light.on diff --git a/homeassistant/components/light/osramlightify.py b/homeassistant/components/light/osramlightify.py index ff526c4783d..939d0fe6988 100644 --- a/homeassistant/components/light/osramlightify.py +++ b/homeassistant/components/light/osramlightify.py @@ -13,33 +13,37 @@ import voluptuous as vol from homeassistant import util from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_RGB_COLOR, - ATTR_TRANSITION, ATTR_XY_COLOR, EFFECT_RANDOM, PLATFORM_SCHEMA, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_RGB_COLOR, - SUPPORT_TRANSITION, SUPPORT_XY_COLOR, Light) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, + ATTR_TRANSITION, EFFECT_RANDOM, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_TRANSITION, + Light) from homeassistant.const import CONF_HOST import homeassistant.helpers.config_validation as cv from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin, - color_xy_brightness_to_RGB) + color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin) +import homeassistant.util.color as color_util REQUIREMENTS = ['lightify==1.0.6.1'] _LOGGER = logging.getLogger(__name__) +CONF_ALLOW_LIGHTIFY_NODES = 'allow_lightify_nodes' CONF_ALLOW_LIGHTIFY_GROUPS = 'allow_lightify_groups' +DEFAULT_ALLOW_LIGHTIFY_NODES = True DEFAULT_ALLOW_LIGHTIFY_GROUPS = True MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) SUPPORT_OSRAMLIGHTIFY = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | - SUPPORT_EFFECT | SUPPORT_RGB_COLOR | - SUPPORT_TRANSITION | SUPPORT_XY_COLOR) + SUPPORT_EFFECT | SUPPORT_COLOR | + SUPPORT_TRANSITION) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_ALLOW_LIGHTIFY_NODES, + default=DEFAULT_ALLOW_LIGHTIFY_NODES): cv.boolean, vol.Optional(CONF_ALLOW_LIGHTIFY_GROUPS, default=DEFAULT_ALLOW_LIGHTIFY_GROUPS): cv.boolean, }) @@ -50,6 +54,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import lightify host = config.get(CONF_HOST) + add_nodes = config.get(CONF_ALLOW_LIGHTIFY_NODES) add_groups = config.get(CONF_ALLOW_LIGHTIFY_GROUPS) try: @@ -60,10 +65,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.exception(msg) return - setup_bridge(bridge, add_devices, add_groups) + setup_bridge(bridge, add_devices, add_nodes, add_groups) -def setup_bridge(bridge, add_devices, add_groups): +def setup_bridge(bridge, add_devices, add_nodes, add_groups): """Set up the Lightify bridge.""" lights = {} @@ -80,14 +85,15 @@ def setup_bridge(bridge, add_devices, add_groups): new_lights = [] - for (light_id, light) in bridge.lights().items(): - if light_id not in lights: - osram_light = OsramLightifyLight( - light_id, light, update_lights) - lights[light_id] = osram_light - new_lights.append(osram_light) - else: - lights[light_id].light = light + if add_nodes: + for (light_id, light) in bridge.lights().items(): + if light_id not in lights: + osram_light = OsramLightifyLight( + light_id, light, update_lights) + lights[light_id] = osram_light + new_lights.append(osram_light) + else: + lights[light_id].light = light if add_groups: for (group_name, group) in bridge.groups().items(): @@ -113,7 +119,7 @@ class Luminary(Light): self.update_lights = update_lights self._luminary = luminary self._brightness = None - self._rgb = [None] + self._hs = None self._name = None self._temperature = None self._state = False @@ -125,9 +131,9 @@ class Luminary(Light): return self._name @property - def rgb_color(self): - """Last RGB color value set.""" - return self._rgb + def hs_color(self): + """Last hs color value set.""" + return self._hs @property def color_temp(self): @@ -158,42 +164,24 @@ class Luminary(Light): """Turn the device on.""" if ATTR_TRANSITION in kwargs: transition = int(kwargs[ATTR_TRANSITION] * 10) - _LOGGER.debug("turn_on requested transition time for light: " - "%s is: %s", self._name, transition) else: transition = 0 - _LOGGER.debug("turn_on requested transition time for light: " - "%s is: %s", self._name, transition) if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] - _LOGGER.debug("turn_on requested brightness for light: %s is: %s ", - self._name, self._brightness) self._luminary.set_luminance( int(self._brightness / 2.55), transition) else: self._luminary.set_onoff(1) - if ATTR_RGB_COLOR in kwargs: - red, green, blue = kwargs[ATTR_RGB_COLOR] - _LOGGER.debug("turn_on requested ATTR_RGB_COLOR for light:" - " %s is: %s %s %s ", - self._name, red, green, blue) - self._luminary.set_rgb(red, green, blue, transition) - - if ATTR_XY_COLOR in kwargs: - x_mired, y_mired = kwargs[ATTR_XY_COLOR] - _LOGGER.debug("turn_on requested ATTR_XY_COLOR for light:" - " %s is: %s,%s", self._name, x_mired, y_mired) - red, green, blue = color_xy_brightness_to_RGB( - x_mired, y_mired, self._brightness) + if ATTR_HS_COLOR in kwargs: + red, green, blue = \ + color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) self._luminary.set_rgb(red, green, blue, transition) if ATTR_COLOR_TEMP in kwargs: color_t = kwargs[ATTR_COLOR_TEMP] kelvin = int(color_temperature_mired_to_kelvin(color_t)) - _LOGGER.debug("turn_on requested set_temperature for light: " - "%s: %s", self._name, kelvin) self._luminary.set_temperature(kelvin, transition) if ATTR_EFFECT in kwargs: @@ -202,23 +190,16 @@ class Luminary(Light): self._luminary.set_rgb( random.randrange(0, 255), random.randrange(0, 255), random.randrange(0, 255), transition) - _LOGGER.debug("turn_on requested random effect for light: " - "%s with transition %s", self._name, transition) self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the device off.""" - _LOGGER.debug("Attempting to turn off light: %s", self._name) if ATTR_TRANSITION in kwargs: transition = int(kwargs[ATTR_TRANSITION] * 10) - _LOGGER.debug("turn_off requested transition time for light:" - " %s is: %s ", self._name, transition) self._luminary.set_luminance(0, transition) else: transition = 0 - _LOGGER.debug("turn_off requested transition time for light:" - " %s is: %s ", self._name, transition) self._luminary.set_onoff(0) self.schedule_update_ha_state() @@ -240,7 +221,8 @@ class OsramLightifyLight(Luminary): """Update status of a light.""" super().update() self._state = self._luminary.on() - self._rgb = self._luminary.rgb() + rgb = self._luminary.rgb() + self._hs = color_util.color_RGB_to_hs(*rgb) o_temp = self._luminary.temp() if o_temp == 0: self._temperature = None @@ -270,7 +252,8 @@ class OsramLightifyGroup(Luminary): self._light_ids = self._luminary.lights() light = self._bridge.lights()[self._light_ids[0]] self._brightness = int(light.lum() * 2.55) - self._rgb = light.rgb() + rgb = light.rgb() + self._hs = color_util.color_RGB_to_hs(*rgb) o_temp = light.temp() if o_temp == 0: self._temperature = None diff --git a/homeassistant/components/light/piglow.py b/homeassistant/components/light/piglow.py index 40798810c0e..755cf9dca66 100644 --- a/homeassistant/components/light/piglow.py +++ b/homeassistant/components/light/piglow.py @@ -11,15 +11,16 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, ATTR_RGB_COLOR, SUPPORT_RGB_COLOR, + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_COLOR, Light, PLATFORM_SCHEMA) from homeassistant.const import CONF_NAME +import homeassistant.util.color as color_util REQUIREMENTS = ['piglow==1.2.4'] _LOGGER = logging.getLogger(__name__) -SUPPORT_PIGLOW = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR) +SUPPORT_PIGLOW = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR) DEFAULT_NAME = 'Piglow' @@ -50,7 +51,7 @@ class PiglowLight(Light): self._name = name self._is_on = False self._brightness = 255 - self._rgb_color = [255, 255, 255] + self._hs_color = [0, 0] @property def name(self): @@ -63,9 +64,9 @@ class PiglowLight(Light): return self._brightness @property - def rgb_color(self): + def hs_color(self): """Read back the color of the light.""" - return self._rgb_color + return self._hs_color @property def supported_features(self): @@ -93,15 +94,15 @@ class PiglowLight(Light): if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] - percent_bright = (self._brightness / 255) - if ATTR_RGB_COLOR in kwargs: - self._rgb_color = kwargs[ATTR_RGB_COLOR] - self._piglow.red(int(self._rgb_color[0] * percent_bright)) - self._piglow.green(int(self._rgb_color[1] * percent_bright)) - self._piglow.blue(int(self._rgb_color[2] * percent_bright)) - else: - self._piglow.all(self._brightness) + if ATTR_HS_COLOR in kwargs: + self._hs_color = kwargs[ATTR_HS_COLOR] + + rgb = color_util.color_hsv_to_RGB( + self._hs_color[0], self._hs_color[1], self._brightness / 255 * 100) + self._piglow.red(rgb[0]) + self._piglow.green(rgb[1]) + self._piglow.blue(rgb[2]) self._piglow.show() self._is_on = True self.schedule_update_ha_state() diff --git a/homeassistant/components/light/qwikswitch.py b/homeassistant/components/light/qwikswitch.py index 63051d2ea8c..528f4f73c53 100644 --- a/homeassistant/components/light/qwikswitch.py +++ b/homeassistant/components/light/qwikswitch.py @@ -4,21 +4,32 @@ Support for Qwikswitch Relays and Dimmers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.qwikswitch/ """ -import logging +from homeassistant.components.qwikswitch import ( + QSToggleEntity, DOMAIN as QWIKSWITCH) +from homeassistant.components.light import SUPPORT_BRIGHTNESS, Light -import homeassistant.components.qwikswitch as qwikswitch - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['qwikswitch'] +DEPENDENCIES = [QWIKSWITCH] -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the lights from the main Qwikswitch component.""" +async def async_setup_platform(hass, _, add_devices, discovery_info=None): + """Add lights from the main Qwikswitch component.""" if discovery_info is None: - _LOGGER.error("Configure Qwikswitch component failed") - return False + return - add_devices(qwikswitch.QSUSB['light']) - return True + qsusb = hass.data[QWIKSWITCH] + devs = [QSLight(qsid, qsusb) for qsid in discovery_info[QWIKSWITCH]] + add_devices(devs) + + +class QSLight(QSToggleEntity, Light): + """Light based on a Qwikswitch relay/dimmer module.""" + + @property + def brightness(self): + """Return the brightness of this light (0-255).""" + return self.device.value if self.device.is_dimmer else None + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS if self.device.is_dimmer else 0 diff --git a/homeassistant/components/light/rpi_gpio_pwm.py b/homeassistant/components/light/rpi_gpio_pwm.py index 55b64bf8a74..9385c4bfb80 100644 --- a/homeassistant/components/light/rpi_gpio_pwm.py +++ b/homeassistant/components/light/rpi_gpio_pwm.py @@ -10,9 +10,10 @@ import voluptuous as vol from homeassistant.const import CONF_NAME, CONF_TYPE from homeassistant.components.light import ( - Light, ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_TRANSITION, - SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, PLATFORM_SCHEMA) + Light, ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_TRANSITION, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util REQUIREMENTS = ['pwmled==1.2.1'] @@ -33,10 +34,10 @@ CONF_LED_TYPE_RGB = 'rgb' CONF_LED_TYPE_RGBW = 'rgbw' CONF_LED_TYPES = [CONF_LED_TYPE_SIMPLE, CONF_LED_TYPE_RGB, CONF_LED_TYPE_RGBW] -DEFAULT_COLOR = [255, 255, 255] +DEFAULT_COLOR = [0, 0] SUPPORT_SIMPLE_LED = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION) -SUPPORT_RGB_LED = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION) +SUPPORT_RGB_LED = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_TRANSITION) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_LEDS): vol.All(cv.ensure_list, [ @@ -169,7 +170,7 @@ class PwmRgbLed(PwmSimpleLed): self._color = DEFAULT_COLOR @property - def rgb_color(self): + def hs_color(self): """Return the color property.""" return self._color @@ -180,8 +181,8 @@ class PwmRgbLed(PwmSimpleLed): def turn_on(self, **kwargs): """Turn on a LED.""" - if ATTR_RGB_COLOR in kwargs: - self._color = kwargs[ATTR_RGB_COLOR] + if ATTR_HS_COLOR in kwargs: + self._color = kwargs[ATTR_HS_COLOR] if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] @@ -209,4 +210,5 @@ def _from_hass_brightness(brightness): def _from_hass_color(color): """Convert Home Assistant RGB list to Color tuple.""" from pwmled import Color - return Color(*tuple(color)) + rgb = color_util.color_hs_to_RGB(*color) + return Color(*tuple(rgb)) diff --git a/homeassistant/components/light/sensehat.py b/homeassistant/components/light/sensehat.py index 6c5467f8c6d..6ab2592cedf 100644 --- a/homeassistant/components/light/sensehat.py +++ b/homeassistant/components/light/sensehat.py @@ -10,15 +10,16 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, ATTR_RGB_COLOR, SUPPORT_RGB_COLOR, + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_COLOR, Light, PLATFORM_SCHEMA) from homeassistant.const import CONF_NAME +import homeassistant.util.color as color_util REQUIREMENTS = ['sense-hat==2.2.0'] _LOGGER = logging.getLogger(__name__) -SUPPORT_SENSEHAT = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR) +SUPPORT_SENSEHAT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR) DEFAULT_NAME = 'sensehat' @@ -49,7 +50,7 @@ class SenseHatLight(Light): self._name = name self._is_on = False self._brightness = 255 - self._rgb_color = [255, 255, 255] + self._hs_color = [0, 0] @property def name(self): @@ -62,12 +63,9 @@ class SenseHatLight(Light): return self._brightness @property - def rgb_color(self): - """Read back the color of the light. - - Returns [r, g, b] list with values in range of 0-255. - """ - return self._rgb_color + def hs_color(self): + """Read back the color of the light.""" + return self._hs_color @property def supported_features(self): @@ -93,14 +91,13 @@ class SenseHatLight(Light): """Instruct the light to turn on and set correct brightness & color.""" if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] - percent_bright = (self._brightness / 255) - if ATTR_RGB_COLOR in kwargs: - self._rgb_color = kwargs[ATTR_RGB_COLOR] + if ATTR_HS_COLOR in kwargs: + self._hs_color = kwargs[ATTR_HS_COLOR] - self._sensehat.clear(int(self._rgb_color[0] * percent_bright), - int(self._rgb_color[1] * percent_bright), - int(self._rgb_color[2] * percent_bright)) + rgb = color_util.color_hsv_to_RGB( + self._hs_color[0], self._hs_color[1], self._brightness / 255 * 100) + self._sensehat.clear(*rgb) self._is_on = True self.schedule_update_ha_state() diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 44e887e62c4..3507c6d2cda 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -15,6 +15,9 @@ turn_on: color_name: description: A human readable color name. example: 'red' + hs_color: + description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. + example: '[300, 70]' xy_color: description: Color for the light in XY-format. example: '[0.52, 0.43]' @@ -179,3 +182,13 @@ xiaomi_miio_set_delayed_turn_off: time_period: description: Time period for the delayed turn off. example: "5, '0:05', {'minutes': 5}" + +yeelight_set_mode: + description: Set a operation mode. + fields: + entity_id: + description: Name of the light entity. + example: 'light.yeelight' + mode: + description: Operation mode. Valid values are 'last', 'normal', 'rgb', 'hsv', 'color_flow', 'moonlight'. + example: 'moonlight' diff --git a/homeassistant/components/light/skybell.py b/homeassistant/components/light/skybell.py index 012190023fa..d32183f1468 100644 --- a/homeassistant/components/light/skybell.py +++ b/homeassistant/components/light/skybell.py @@ -8,10 +8,11 @@ import logging from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, - SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, Light) + ATTR_BRIGHTNESS, ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light) from homeassistant.components.skybell import ( DOMAIN as SKYBELL_DOMAIN, SkybellDevice) +import homeassistant.util.color as color_util DEPENDENCIES = ['skybell'] @@ -54,8 +55,9 @@ class SkybellLight(SkybellDevice, Light): def turn_on(self, **kwargs): """Turn on the light.""" - if ATTR_RGB_COLOR in kwargs: - self._device.led_rgb = kwargs[ATTR_RGB_COLOR] + if ATTR_HS_COLOR in kwargs: + rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) + self._device.led_rgb = rgb elif ATTR_BRIGHTNESS in kwargs: self._device.led_intensity = _to_skybell_level( kwargs[ATTR_BRIGHTNESS]) @@ -77,11 +79,11 @@ class SkybellLight(SkybellDevice, Light): return _to_hass_level(self._device.led_intensity) @property - def rgb_color(self): + def hs_color(self): """Return the color of the light.""" - return self._device.led_rgb + return color_util.color_RGB_to_hs(*self._device.led_rgb) @property def supported_features(self): """Flag supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR diff --git a/homeassistant/components/light/tikteck.py b/homeassistant/components/light/tikteck.py index c39748e4430..2079638f7f1 100644 --- a/homeassistant/components/light/tikteck.py +++ b/homeassistant/components/light/tikteck.py @@ -10,15 +10,16 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_PASSWORD from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, + ATTR_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util REQUIREMENTS = ['tikteck==0.4'] _LOGGER = logging.getLogger(__name__) -SUPPORT_TIKTECK_LED = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR) +SUPPORT_TIKTECK_LED = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR) DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, @@ -57,7 +58,7 @@ class TikteckLight(Light): self._address = device['address'] self._password = device['password'] self._brightness = 255 - self._rgb = [255, 255, 255] + self._hs = [0, 0] self._state = False self.is_valid = True self._bulb = tikteck.tikteck( @@ -88,9 +89,9 @@ class TikteckLight(Light): return self._brightness @property - def rgb_color(self): + def hs_color(self): """Return the color property.""" - return self._rgb + return self._hs @property def supported_features(self): @@ -115,16 +116,17 @@ class TikteckLight(Light): """Turn the specified light on.""" self._state = True - rgb = kwargs.get(ATTR_RGB_COLOR) + hs_color = kwargs.get(ATTR_HS_COLOR) brightness = kwargs.get(ATTR_BRIGHTNESS) - if rgb is not None: - self._rgb = rgb + if hs_color is not None: + self._hs = hs_color if brightness is not None: self._brightness = brightness - self.set_state(self._rgb[0], self._rgb[1], self._rgb[2], - self.brightness) + rgb = color_util.color_hs_to_RGB(*self._hs) + + self.set_state(rgb[0], rgb[1], rgb[2], self.brightness) self.schedule_update_ha_state() def turn_off(self, **kwargs): diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index f87d624b83a..4101eab2150 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -5,23 +5,20 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/light.tplink/ """ import logging -import colorsys import time import voluptuous as vol from homeassistant.const import (CONF_HOST, CONF_NAME) from homeassistant.components.light import ( - Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_KELVIN, ATTR_RGB_COLOR, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, PLATFORM_SCHEMA) + Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR_TEMP, SUPPORT_COLOR, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv from homeassistant.util.color import \ color_temperature_mired_to_kelvin as mired_to_kelvin from homeassistant.util.color import ( color_temperature_kelvin_to_mired as kelvin_to_mired) -from typing import Tuple - REQUIREMENTS = ['pyHS100==0.3.0'] _LOGGER = logging.getLogger(__name__) @@ -56,22 +53,6 @@ def brightness_from_percentage(percent): return (percent*255.0)/100.0 -# Travis-CI runs too old astroid https://github.com/PyCQA/pylint/issues/1212 -# pylint: disable=invalid-sequence-index -def rgb_to_hsv(rgb: Tuple[float, float, float]) -> Tuple[int, int, int]: - """Convert RGB tuple (values 0-255) to HSV (degrees, %, %).""" - hue, sat, value = colorsys.rgb_to_hsv(rgb[0]/255, rgb[1]/255, rgb[2]/255) - return int(hue * 360), int(sat * 100), int(value * 100) - - -# Travis-CI runs too old astroid https://github.com/PyCQA/pylint/issues/1212 -# pylint: disable=invalid-sequence-index -def hsv_to_rgb(hsv: Tuple[float, float, float]) -> Tuple[int, int, int]: - """Convert HSV tuple (degrees, %, %) to RGB (values 0-255).""" - red, green, blue = colorsys.hsv_to_rgb(hsv[0]/360, hsv[1]/100, hsv[2]/100) - return int(red * 255), int(green * 255), int(blue * 255) - - class TPLinkSmartBulb(Light): """Representation of a TPLink Smart Bulb.""" @@ -83,7 +64,7 @@ class TPLinkSmartBulb(Light): self._available = True self._color_temp = None self._brightness = None - self._rgb = None + self._hs = None self._supported_features = 0 self._emeter_params = {} @@ -109,14 +90,15 @@ class TPLinkSmartBulb(Light): if ATTR_COLOR_TEMP in kwargs: self.smartbulb.color_temp = \ mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) - if ATTR_KELVIN in kwargs: - self.smartbulb.color_temp = kwargs[ATTR_KELVIN] - if ATTR_BRIGHTNESS in kwargs: - brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255) - self.smartbulb.brightness = brightness_to_percentage(brightness) - if ATTR_RGB_COLOR in kwargs: - rgb = kwargs.get(ATTR_RGB_COLOR) - self.smartbulb.hsv = rgb_to_hsv(rgb) + + brightness = brightness_to_percentage( + kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255)) + if ATTR_HS_COLOR in kwargs: + hue, sat = kwargs.get(ATTR_HS_COLOR) + hsv = (int(hue), int(sat), brightness) + self.smartbulb.hsv = hsv + elif ATTR_BRIGHTNESS in kwargs: + self.smartbulb.brightness = brightness def turn_off(self, **kwargs): """Turn the light off.""" @@ -133,9 +115,9 @@ class TPLinkSmartBulb(Light): return self._brightness @property - def rgb_color(self): - """Return the color in RGB.""" - return self._rgb + def hs_color(self): + """Return the color.""" + return self._hs @property def is_on(self): @@ -168,8 +150,9 @@ class TPLinkSmartBulb(Light): self._color_temp = kelvin_to_mired( self.smartbulb.color_temp) - if self._supported_features & SUPPORT_RGB_COLOR: - self._rgb = hsv_to_rgb(self.smartbulb.hsv) + if self._supported_features & SUPPORT_COLOR: + hue, sat, _ = self.smartbulb.hsv + self._hs = (hue, sat) if self.smartbulb.has_emeter: self._emeter_params[ATTR_CURRENT_POWER_W] = '{:.1f}'.format( @@ -203,4 +186,4 @@ class TPLinkSmartBulb(Light): if self.smartbulb.is_variable_color_temp: self._supported_features += SUPPORT_COLOR_TEMP if self.smartbulb.is_color: - self._supported_features += SUPPORT_RGB_COLOR + self._supported_features += SUPPORT_COLOR diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index bb2fa44c15c..ab53c3669cb 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -4,33 +4,31 @@ Support for the IKEA Tradfri platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.tradfri/ """ -import asyncio import logging from homeassistant.core import callback -from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION, + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION, SUPPORT_COLOR_TEMP, - SUPPORT_RGB_COLOR, Light) + SUPPORT_COLOR, Light) from homeassistant.components.light import \ PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA from homeassistant.components.tradfri import KEY_GATEWAY, KEY_TRADFRI_GROUPS, \ KEY_API -from homeassistant.util import color as color_util +import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) +ATTR_TRANSITION_TIME = 'transition_time' DEPENDENCIES = ['tradfri'] PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA IKEA = 'IKEA of Sweden' TRADFRI_LIGHT_MANAGER = 'Tradfri Light Manager' SUPPORTED_FEATURES = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION) -ALLOWED_TEMPERATURES = {IKEA} -@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 IKEA Tradfri Light platform.""" if discovery_info is None: return @@ -40,41 +38,43 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): gateway = hass.data[KEY_GATEWAY][gateway_id] devices_command = gateway.get_devices() - devices_commands = yield from api(devices_command) - devices = yield from api(devices_commands) + devices_commands = await api(devices_command) + devices = await api(devices_commands) lights = [dev for dev in devices if dev.has_light_control] if lights: - async_add_devices(TradfriLight(light, api) for light in lights) + async_add_devices( + TradfriLight(light, api, gateway_id) for light in lights) allow_tradfri_groups = hass.data[KEY_TRADFRI_GROUPS][gateway_id] if allow_tradfri_groups: groups_command = gateway.get_groups() - groups_commands = yield from api(groups_command) - groups = yield from api(groups_commands) + groups_commands = await api(groups_command) + groups = await api(groups_commands) if groups: - async_add_devices(TradfriGroup(group, api) for group in groups) + async_add_devices( + TradfriGroup(group, api, gateway_id) for group in groups) class TradfriGroup(Light): """The platform class required by hass.""" - def __init__(self, light, api): + def __init__(self, group, api, gateway_id): """Initialize a Group.""" self._api = api - self._group = light - self._name = light.name + self._unique_id = "group-{}-{}".format(gateway_id, group.id) + self._group = group + self._name = group.name - self._refresh(light) + self._refresh(group) - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Start thread when added to hass.""" self._async_start_observe() @property - def should_poll(self): - """No polling needed for tradfri group.""" - return False + def unique_id(self): + """Return unique ID for this group.""" + return self._unique_id @property def supported_features(self): @@ -96,13 +96,11 @@ class TradfriGroup(Light): """Return the brightness of the group lights.""" return self._group.dimmer - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Instruct the group lights to turn off.""" - yield from self._api(self._group.set_state(0)) + await self._api(self._group.set_state(0)) - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Instruct the group lights to turn on, or dim.""" keys = {} if ATTR_TRANSITION in kwargs: @@ -112,16 +110,16 @@ class TradfriGroup(Light): if kwargs[ATTR_BRIGHTNESS] == 255: kwargs[ATTR_BRIGHTNESS] = 254 - yield from self._api( + await self._api( self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS], **keys)) else: - yield from self._api(self._group.set_state(1)) + await self._api(self._group.set_state(1)) @callback def _async_start_observe(self, exc=None): """Start observation of light.""" # pylint: disable=import-error - from pytradfri.error import PyTradFriError + from pytradfri.error import PytradfriError if exc: _LOGGER.warning("Observation failed for %s", self._name, exc_info=exc) @@ -131,7 +129,7 @@ class TradfriGroup(Light): err_callback=self._async_start_observe, duration=0) self.hass.async_add_job(self._api(cmd)) - except PyTradFriError as err: + except PytradfriError as err: _LOGGER.warning("Observation failed, trying again", exc_info=err) self._async_start_observe() @@ -146,54 +144,44 @@ class TradfriGroup(Light): self._refresh(tradfri_device) self.async_schedule_update_ha_state() + async def async_update(self): + """Fetch new state data for the group.""" + await self._api(self._group.update()) + class TradfriLight(Light): """The platform class required by Home Assistant.""" - def __init__(self, light, api): + def __init__(self, light, api, gateway_id): """Initialize a Light.""" self._api = api + self._unique_id = "light-{}-{}".format(gateway_id, light.id) self._light = None self._light_control = None self._light_data = None self._name = None - self._rgb_color = None + self._hs_color = None self._features = SUPPORTED_FEATURES - self._temp_supported = False self._available = True self._refresh(light) + @property + def unique_id(self): + """Return unique ID for light.""" + return self._unique_id + @property def min_mireds(self): """Return the coldest color_temp that this light supports.""" - if self._light_control.max_kelvin is not None: - return color_util.color_temperature_kelvin_to_mired( - self._light_control.max_kelvin - ) + return self._light_control.min_mireds @property def max_mireds(self): """Return the warmest color_temp that this light supports.""" - if self._light_control.min_kelvin is not None: - return color_util.color_temperature_kelvin_to_mired( - self._light_control.min_kelvin - ) + return self._light_control.max_mireds - @property - def device_state_attributes(self): - """Return the devices' state attributes.""" - info = self._light.device_info - - attrs = {} - - if info.battery_level is not None: - attrs[ATTR_BATTERY_LEVEL] = info.battery_level - - return attrs - - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Start thread when added to hass.""" self._async_start_observe() @@ -229,64 +217,89 @@ class TradfriLight(Light): @property def color_temp(self): - """Return the CT color value in mireds.""" - kelvin_color = self._light_data.kelvin_color_inferred - if kelvin_color is not None: - return color_util.color_temperature_kelvin_to_mired( - kelvin_color - ) + """Return the color temp value in mireds.""" + return self._light_data.color_temp @property - def rgb_color(self): - """RGB color of the light.""" - return self._rgb_color + def hs_color(self): + """HS color of the light.""" + if self._light_control.can_set_color: + hsbxy = self._light_data.hsb_xy_color + hue = hsbxy[0] / (65535 / 360) + sat = hsbxy[1] / (65279 / 100) + if hue is not None and sat is not None: + return hue, sat - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Instruct the light to turn off.""" - yield from self._api(self._light_control.set_state(False)) + await self._api(self._light_control.set_state(False)) - @asyncio.coroutine - def async_turn_on(self, **kwargs): - """ - Instruct the light to turn on. - - After adding "self._light_data.hexcolor is not None" - for ATTR_RGB_COLOR, this also supports Philips Hue bulbs. - """ - if ATTR_RGB_COLOR in kwargs and self._light_data.hex_color is not None: - yield from self._api( - self._light.light_control.set_rgb_color( - *kwargs[ATTR_RGB_COLOR])) - - elif ATTR_COLOR_TEMP in kwargs and \ - self._light_data.hex_color is not None and \ - self._temp_supported: - kelvin = color_util.color_temperature_mired_to_kelvin( - kwargs[ATTR_COLOR_TEMP]) - yield from self._api( - self._light_control.set_kelvin_color(kelvin)) - - keys = {} + async def async_turn_on(self, **kwargs): + """Instruct the light to turn on.""" + params = {} + transition_time = None if ATTR_TRANSITION in kwargs: - keys['transition_time'] = int(kwargs[ATTR_TRANSITION]) * 10 + transition_time = int(kwargs[ATTR_TRANSITION]) * 10 - if ATTR_BRIGHTNESS in kwargs: - if kwargs[ATTR_BRIGHTNESS] == 255: - kwargs[ATTR_BRIGHTNESS] = 254 + brightness = kwargs.get(ATTR_BRIGHTNESS) - yield from self._api( - self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS], - **keys)) + if brightness is not None: + if brightness > 254: + brightness = 254 + elif brightness < 0: + brightness = 0 + + if ATTR_HS_COLOR in kwargs and self._light_control.can_set_color: + params[ATTR_BRIGHTNESS] = brightness + hue = int(kwargs[ATTR_HS_COLOR][0] * (65535 / 360)) + sat = int(kwargs[ATTR_HS_COLOR][1] * (65279 / 100)) + if brightness is None: + params[ATTR_TRANSITION_TIME] = transition_time + await self._api( + self._light_control.set_hsb(hue, sat, **params)) + return + + if ATTR_COLOR_TEMP in kwargs and (self._light_control.can_set_temp or + self._light_control.can_set_color): + temp = kwargs[ATTR_COLOR_TEMP] + if temp > self.max_mireds: + temp = self.max_mireds + elif temp < self.min_mireds: + temp = self.min_mireds + + if brightness is None: + params[ATTR_TRANSITION_TIME] = transition_time + # White Spectrum bulb + if (self._light_control.can_set_temp and + not self._light_control.can_set_color): + await self._api( + self._light_control.set_color_temp(temp, **params)) + # Color bulb (CWS) + # color_temp needs to be set with hue/saturation + if self._light_control.can_set_color: + params[ATTR_BRIGHTNESS] = brightness + temp_k = color_util.color_temperature_mired_to_kelvin(temp) + hs_color = color_util.color_temperature_to_hs(temp_k) + hue = int(hs_color[0] * (65535 / 360)) + sat = int(hs_color[1] * (65279 / 100)) + await self._api( + self._light_control.set_hsb(hue, sat, + **params)) + + if brightness is not None: + params[ATTR_TRANSITION_TIME] = transition_time + await self._api( + self._light_control.set_dimmer(brightness, + **params)) else: - yield from self._api( + await self._api( self._light_control.set_state(True)) @callback def _async_start_observe(self, exc=None): """Start observation of light.""" # pylint: disable=import-error - from pytradfri.error import PyTradFriError + from pytradfri.error import PytradfriError if exc: _LOGGER.warning("Observation failed for %s", self._name, exc_info=exc) @@ -296,7 +309,7 @@ class TradfriLight(Light): err_callback=self._async_start_observe, duration=0) self.hass.async_add_job(self._api(cmd)) - except PyTradFriError as err: + except PytradfriError as err: _LOGGER.warning("Observation failed, trying again", exc_info=err) self._async_start_observe() @@ -309,26 +322,15 @@ class TradfriLight(Light): self._light_control = light.light_control self._light_data = light.light_control.lights[0] self._name = light.name - self._rgb_color = None self._features = SUPPORTED_FEATURES - if self._light.device_info.manufacturer == IKEA: - if self._light_control.can_set_kelvin: - self._features |= SUPPORT_COLOR_TEMP - if self._light_control.can_set_color: - self._features |= SUPPORT_RGB_COLOR - else: - if self._light_data.hex_color is not None: - self._features |= SUPPORT_RGB_COLOR - - self._temp_supported = self._light.device_info.manufacturer \ - in ALLOWED_TEMPERATURES + if light.light_control.can_set_color: + self._features |= SUPPORT_COLOR + if light.light_control.can_set_temp: + self._features |= SUPPORT_COLOR_TEMP @callback def _observe_update(self, tradfri_device): """Receive new state data for this light.""" self._refresh(tradfri_device) - self._rgb_color = color_util.rgb_hex_to_rgb_list( - self._light_data.hex_color_inferred - ) self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/vera.py b/homeassistant/components/light/vera.py index 102ca814882..6b12e69341d 100644 --- a/homeassistant/components/light/vera.py +++ b/homeassistant/components/light/vera.py @@ -7,10 +7,11 @@ https://home-assistant.io/components/light.vera/ import logging from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ENTITY_ID_FORMAT, - SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, Light) + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ENTITY_ID_FORMAT, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light) from homeassistant.components.vera import ( VERA_CONTROLLER, VERA_DEVICES, VeraDevice) +import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -42,7 +43,7 @@ class VeraLight(VeraDevice, Light): return self._brightness @property - def rgb_color(self): + def hs_color(self): """Return the color of the light.""" return self._color @@ -50,13 +51,14 @@ class VeraLight(VeraDevice, Light): def supported_features(self): """Flag supported features.""" if self._color: - return SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR return SUPPORT_BRIGHTNESS def turn_on(self, **kwargs): """Turn the light on.""" - if ATTR_RGB_COLOR in kwargs and self._color: - self.vera_device.set_color(kwargs[ATTR_RGB_COLOR]) + if ATTR_HS_COLOR in kwargs and self._color: + rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) + self.vera_device.set_color(rgb) elif ATTR_BRIGHTNESS in kwargs and self.vera_device.is_dimmable: self.vera_device.set_brightness(kwargs[ATTR_BRIGHTNESS]) else: @@ -83,4 +85,5 @@ class VeraLight(VeraDevice, Light): # If it is dimmable, both functions exist. In case color # is not supported, it will return None self._brightness = self.vera_device.get_brightness() - self._color = self.vera_device.get_color() + rgb = self.vera_device.get_color() + self._color = color_util.color_RGB_to_hs(*rgb) if rgb else None diff --git a/homeassistant/components/light/wemo.py b/homeassistant/components/light/wemo.py index 540c718b04d..fcf3d2f7a7d 100644 --- a/homeassistant/components/light/wemo.py +++ b/homeassistant/components/light/wemo.py @@ -9,12 +9,10 @@ import logging from datetime import timedelta import homeassistant.util as util -import homeassistant.util.color as color_util from homeassistant.components.light import ( - Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION, - ATTR_XY_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, - SUPPORT_TRANSITION, SUPPORT_XY_COLOR) -from homeassistant.loader import get_component + Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, SUPPORT_TRANSITION) +import homeassistant.util.color as color_util DEPENDENCIES = ['wemo'] @@ -23,8 +21,8 @@ MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) _LOGGER = logging.getLogger(__name__) -SUPPORT_WEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR | - SUPPORT_TRANSITION | SUPPORT_XY_COLOR) +SUPPORT_WEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR | + SUPPORT_TRANSITION) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -89,9 +87,10 @@ class WemoLight(Light): return self.device.state.get('level', 255) @property - def xy_color(self): - """Return the XY color values of this light.""" - return self.device.state.get('color_xy') + def hs_color(self): + """Return the hs color values of this light.""" + xy_color = self.device.state.get('color_xy') + return color_util.color_xy_to_hs(*xy_color) if xy_color else None @property def color_temp(self): @@ -112,17 +111,11 @@ class WemoLight(Light): """Turn the light on.""" transitiontime = int(kwargs.get(ATTR_TRANSITION, 0)) - if ATTR_XY_COLOR in kwargs: - xycolor = kwargs[ATTR_XY_COLOR] - elif ATTR_RGB_COLOR in kwargs: - xycolor = color_util.color_RGB_to_xy( - *(int(val) for val in kwargs[ATTR_RGB_COLOR])) - kwargs.setdefault(ATTR_BRIGHTNESS, xycolor[2]) - else: - xycolor = None + hs_color = kwargs.get(ATTR_HS_COLOR) - if xycolor is not None: - self.device.set_color(xycolor, transition=transitiontime) + if hs_color is not None: + xy_color = color_util.color_hs_to_xy(*hs_color) + self.device.set_color(xy_color, transition=transitiontime) if ATTR_COLOR_TEMP in kwargs: colortemp = kwargs[ATTR_COLOR_TEMP] @@ -157,7 +150,7 @@ class WemoDimmer(Light): @asyncio.coroutine def async_added_to_hass(self): """Register update callback.""" - wemo = get_component('wemo') + wemo = self.hass.components.wemo # The register method uses a threading condition, so call via executor. # and yield from to wait until the task is done. yield from self.hass.async_add_job( diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index e329fa04837..a2cc4fd7aeb 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -5,11 +5,10 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.wink/ """ import asyncio -import colorsys from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, - SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR_TEMP, SUPPORT_COLOR, Light) from homeassistant.components.wink import DOMAIN, WinkDevice from homeassistant.util import color as color_util from homeassistant.util.color import \ @@ -17,8 +16,6 @@ from homeassistant.util.color import \ DEPENDENCIES = ['wink'] -SUPPORT_WINK = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Wink lights.""" @@ -55,28 +52,18 @@ class WinkLight(WinkDevice, Light): return None @property - def rgb_color(self): - """Define current bulb color in RGB.""" - if not self.wink.supports_hue_saturation(): - return None - else: + def hs_color(self): + """Define current bulb color.""" + if self.wink.supports_xy_color(): + return color_util.color_xy_to_hs(*self.wink.color_xy()) + + if self.wink.supports_hue_saturation(): hue = self.wink.color_hue() saturation = self.wink.color_saturation() - value = int(self.wink.brightness() * 255) - if hue is None or saturation is None or value is None: - return None - rgb = colorsys.hsv_to_rgb(hue, saturation, value) - r_value = int(round(rgb[0])) - g_value = int(round(rgb[1])) - b_value = int(round(rgb[2])) - return r_value, g_value, b_value + if hue is not None and saturation is not None: + return hue*360, saturation*100 - @property - def xy_color(self): - """Define current bulb color in CIE 1931 (XY) color space.""" - if not self.wink.supports_xy_color(): - return None - return self.wink.color_xy() + return None @property def color_temp(self): @@ -89,26 +76,30 @@ class WinkLight(WinkDevice, Light): @property def supported_features(self): """Flag supported features.""" - return SUPPORT_WINK + supports = SUPPORT_BRIGHTNESS + if self.wink.supports_temperature(): + supports = supports | SUPPORT_COLOR_TEMP + if self.wink.supports_xy_color(): + supports = supports | SUPPORT_COLOR + elif self.wink.supports_hue_saturation(): + supports = supports | SUPPORT_COLOR + return supports def turn_on(self, **kwargs): """Turn the switch on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) - rgb_color = kwargs.get(ATTR_RGB_COLOR) + hs_color = kwargs.get(ATTR_HS_COLOR) color_temp_mired = kwargs.get(ATTR_COLOR_TEMP) - state_kwargs = { - } + state_kwargs = {} - if rgb_color: + if hs_color: if self.wink.supports_xy_color(): - xyb = color_util.color_RGB_to_xy(*rgb_color) - state_kwargs['color_xy'] = xyb[0], xyb[1] - state_kwargs['brightness'] = xyb[2] + xy_color = color_util.color_hs_to_xy(*hs_color) + state_kwargs['color_xy'] = xy_color if self.wink.supports_hue_saturation(): - hsv = colorsys.rgb_to_hsv( - rgb_color[0], rgb_color[1], rgb_color[2]) - state_kwargs['color_hue_saturation'] = hsv[0], hsv[1] + hs_scaled = hs_color[0]/360, hs_color[1]/100 + state_kwargs['color_hue_saturation'] = hs_scaled if color_temp_mired: state_kwargs['color_kelvin'] = mired_to_kelvin(color_temp_mired) diff --git a/homeassistant/components/light/xiaomi_aqara.py b/homeassistant/components/light/xiaomi_aqara.py index efe37d3d577..37ae60e3494 100644 --- a/homeassistant/components/light/xiaomi_aqara.py +++ b/homeassistant/components/light/xiaomi_aqara.py @@ -4,9 +4,10 @@ import struct import binascii from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, XiaomiDevice) -from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR, +from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, - SUPPORT_RGB_COLOR, Light) + SUPPORT_COLOR, Light) +import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -17,7 +18,7 @@ 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['light']: model = device['model'] - if model == 'gateway': + if model in ['gateway', 'gateway.v3']: devices.append(XiaomiGatewayLight(device, 'Gateway Light', gateway)) add_devices(devices) @@ -29,7 +30,7 @@ class XiaomiGatewayLight(XiaomiDevice, Light): def __init__(self, device, name, xiaomi_hub): """Initialize the XiaomiGatewayLight.""" self._data_key = 'rgb' - self._rgb = (255, 255, 255) + self._hs = (0, 0) self._brightness = 180 XiaomiDevice.__init__(self, device, name, xiaomi_hub) @@ -64,7 +65,7 @@ class XiaomiGatewayLight(XiaomiDevice, Light): rgb = rgba[1:] self._brightness = int(255 * brightness / 100) - self._rgb = rgb + self._hs = color_util.color_RGB_to_hs(*rgb) self._state = True return True @@ -74,24 +75,25 @@ class XiaomiGatewayLight(XiaomiDevice, Light): return self._brightness @property - def rgb_color(self): - """Return the RBG color value.""" - return self._rgb + def hs_color(self): + """Return the hs color value.""" + return self._hs @property def supported_features(self): """Return the supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR def turn_on(self, **kwargs): """Turn the light on.""" - if ATTR_RGB_COLOR in kwargs: - self._rgb = kwargs[ATTR_RGB_COLOR] + if ATTR_HS_COLOR in kwargs: + self._hs = kwargs[ATTR_HS_COLOR] if ATTR_BRIGHTNESS in kwargs: self._brightness = int(100 * kwargs[ATTR_BRIGHTNESS] / 255) - rgba = (self._brightness,) + self._rgb + rgb = color_util.color_hs_to_RGB(*self._hs) + rgba = (self._brightness,) + rgb rgbhex = binascii.hexlify(struct.pack('BBBB', *rgba)).decode("ASCII") rgbhex = int(rgbhex, 16) diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index 77b02600f33..24eab7ebd4a 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -37,25 +37,38 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ ['philips.light.sread1', 'philips.light.ceiling', 'philips.light.zyceiling', - 'philips.light.bulb']), + 'philips.light.bulb', + 'philips.light.candle', + 'philips.light.candle2']), }) -REQUIREMENTS = ['python-miio==0.3.8'] +REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] # The light does not accept cct values < 1 CCT_MIN = 1 CCT_MAX = 100 -DELAYED_TURN_OFF_MAX_DEVIATION = 4 +DELAYED_TURN_OFF_MAX_DEVIATION_SECONDS = 4 +DELAYED_TURN_OFF_MAX_DEVIATION_MINUTES = 1 SUCCESS = ['ok'] ATTR_MODEL = 'model' ATTR_SCENE = 'scene' ATTR_DELAYED_TURN_OFF = 'delayed_turn_off' ATTR_TIME_PERIOD = 'time_period' +ATTR_NIGHT_LIGHT_MODE = 'night_light_mode' +ATTR_AUTOMATIC_COLOR_TEMPERATURE = 'automatic_color_temperature' +ATTR_REMINDER = 'reminder' +ATTR_EYECARE_MODE = 'eyecare_mode' SERVICE_SET_SCENE = 'xiaomi_miio_set_scene' SERVICE_SET_DELAYED_TURN_OFF = 'xiaomi_miio_set_delayed_turn_off' +SERVICE_REMINDER_ON = 'xiaomi_miio_reminder_on' +SERVICE_REMINDER_OFF = 'xiaomi_miio_reminder_off' +SERVICE_NIGHT_LIGHT_MODE_ON = 'xiaomi_miio_night_light_mode_on' +SERVICE_NIGHT_LIGHT_MODE_OFF = 'xiaomi_miio_night_light_mode_off' +SERVICE_EYECARE_MODE_ON = 'xiaomi_miio_eyecare_mode_on' +SERVICE_EYECARE_MODE_OFF = 'xiaomi_miio_eyecare_mode_off' XIAOMI_MIIO_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, @@ -78,12 +91,18 @@ SERVICE_TO_METHOD = { SERVICE_SET_SCENE: { 'method': 'async_set_scene', 'schema': SERVICE_SCHEMA_SET_SCENE}, + SERVICE_REMINDER_ON: {'method': 'async_reminder_on'}, + SERVICE_REMINDER_OFF: {'method': 'async_reminder_off'}, + SERVICE_NIGHT_LIGHT_MODE_ON: {'method': 'async_night_light_mode_on'}, + SERVICE_NIGHT_LIGHT_MODE_OFF: {'method': 'async_night_light_mode_off'}, + SERVICE_EYECARE_MODE_ON: {'method': 'async_eyecare_mode_on'}, + SERVICE_EYECARE_MODE_OFF: {'method': 'async_eyecare_mode_off'}, } # pylint: disable=unused-argument -@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 light from config.""" from miio import Device, DeviceException if DATA_KEY not in hass.data: @@ -96,11 +115,15 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + devices = [] + unique_id = None + if model is None: try: miio_device = Device(host, token) device_info = miio_device.info() model = device_info.model + unique_id = "{}-{}".format(model, device_info.mac_address) _LOGGER.info("%s %s %s detected", model, device_info.firmware_version, @@ -111,27 +134,40 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if model == 'philips.light.sread1': from miio import PhilipsEyecare light = PhilipsEyecare(host, token) - device = XiaomiPhilipsEyecareLamp(name, light, model) + primary_device = XiaomiPhilipsEyecareLamp( + name, light, model, unique_id) + devices.append(primary_device) + hass.data[DATA_KEY][host] = primary_device + + secondary_device = XiaomiPhilipsEyecareLampAmbientLight( + name, light, model, unique_id) + devices.append(secondary_device) + # The ambient light doesn't expose additional services. + # A hass.data[DATA_KEY] entry isn't needed. elif model in ['philips.light.ceiling', 'philips.light.zyceiling']: from miio import Ceil light = Ceil(host, token) - device = XiaomiPhilipsCeilingLamp(name, light, model) - elif model == 'philips.light.bulb': + device = XiaomiPhilipsCeilingLamp(name, light, model, unique_id) + devices.append(device) + hass.data[DATA_KEY][host] = device + elif model in ['philips.light.bulb', + 'philips.light.candle', + 'philips.light.candle2']: from miio import PhilipsBulb light = PhilipsBulb(host, token) - device = XiaomiPhilipsLightBall(name, light, model) + device = XiaomiPhilipsBulb(name, light, model, unique_id) + devices.append(device) + hass.data[DATA_KEY][host] = device else: _LOGGER.error( 'Unsupported device found! Please create an issue at ' - 'https://github.com/rytilahti/python-miio/issues ' + 'https://github.com/syssi/philipslight/issues ' 'and provide the following data: %s', model) return False - hass.data[DATA_KEY][host] = device - async_add_devices([device], update_before_add=True) + async_add_devices(devices, update_before_add=True) - @asyncio.coroutine - def async_service_handler(service): + async def async_service_handler(service): """Map services to methods on Xiaomi Philips Lights.""" method = SERVICE_TO_METHOD.get(service.service) params = {key: value for key, value in service.data.items() @@ -145,11 +181,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): update_tasks = [] for target_device in target_devices: - yield from getattr(target_device, method['method'])(**params) + if not hasattr(target_device, method['method']): + continue + await getattr(target_device, method['method'])(**params) update_tasks.append(target_device.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) for xiaomi_miio_service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[xiaomi_miio_service].get( @@ -158,23 +196,22 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): DOMAIN, xiaomi_miio_service, async_service_handler, schema=schema) -class XiaomiPhilipsGenericLight(Light): - """Representation of a Xiaomi Philips Light.""" +class XiaomiPhilipsAbstractLight(Light): + """Representation of a Abstract Xiaomi Philips Light.""" - def __init__(self, name, light, model): + def __init__(self, name, light, model, unique_id): """Initialize the light device.""" self._name = name + self._light = light self._model = model + self._unique_id = unique_id self._brightness = None - self._color_temp = None - self._light = light + self._available = False self._state = None self._state_attrs = { ATTR_MODEL: self._model, - ATTR_SCENE: None, - ATTR_DELAYED_TURN_OFF: None, } @property @@ -182,6 +219,11 @@ class XiaomiPhilipsGenericLight(Light): """Poll the light.""" return True + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + @property def name(self): """Return the name of the device if any.""" @@ -190,7 +232,7 @@ class XiaomiPhilipsGenericLight(Light): @property def available(self): """Return true when state is known.""" - return self._state is not None + return self._available @property def device_state_attributes(self): @@ -212,12 +254,11 @@ class XiaomiPhilipsGenericLight(Light): """Return the supported features.""" return SUPPORT_BRIGHTNESS - @asyncio.coroutine - def _try_command(self, mask_error, func, *args, **kwargs): + async def _try_command(self, mask_error, func, *args, **kwargs): """Call a light command handling error messages.""" from miio import DeviceException try: - result = yield from self.hass.async_add_job( + result = await self.hass.async_add_job( partial(func, *args, **kwargs)) _LOGGER.debug("Response received from light: %s", result) @@ -225,10 +266,10 @@ class XiaomiPhilipsGenericLight(Light): return result == SUCCESS except DeviceException as exc: _LOGGER.error(mask_error, exc) + self._available = False return False - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] @@ -238,30 +279,57 @@ class XiaomiPhilipsGenericLight(Light): "Setting brightness: %s %s%%", brightness, percent_brightness) - result = yield from self._try_command( + result = await self._try_command( "Setting brightness failed: %s", self._light.set_brightness, percent_brightness) if result: self._brightness = brightness else: - yield from self._try_command( + await self._try_command( "Turning the light on failed.", self._light.on) - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the light off.""" - yield from self._try_command( + await self._try_command( "Turning the light off failed.", self._light.off) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Fetch state from the device.""" from miio import DeviceException try: - state = yield from self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_job(self._light.status) _LOGGER.debug("Got new state: %s", state) + self._available = True + self._state = state.is_on + self._brightness = ceil((255 / 100.0) * state.brightness) + + except DeviceException as ex: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) + + +class XiaomiPhilipsGenericLight(XiaomiPhilipsAbstractLight): + """Representation of a Generic Xiaomi Philips Light.""" + + def __init__(self, name, light, model, unique_id): + """Initialize the light device.""" + super().__init__(name, light, model, unique_id) + + self._state_attrs.update({ + ATTR_SCENE: None, + ATTR_DELAYED_TURN_OFF: None, + }) + + async def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + try: + state = await self.hass.async_add_job(self._light.status) + _LOGGER.debug("Got new state: %s", state) + + self._available = True self._state = state.is_on self._brightness = ceil((255 / 100.0) * state.brightness) @@ -276,45 +344,35 @@ class XiaomiPhilipsGenericLight(Light): }) except DeviceException as ex: - self._state = None + self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) - @asyncio.coroutine - def async_set_scene(self, scene: int = 1): + async def async_set_scene(self, scene: int = 1): """Set the fixed scene.""" - yield from self._try_command( + await self._try_command( "Setting a fixed scene failed.", self._light.set_scene, scene) - @asyncio.coroutine - def async_set_delayed_turn_off(self, time_period: timedelta): - """Set delay off. The unit is different per device.""" - yield from self._try_command( - "Setting the delay off failed.", + async def async_set_delayed_turn_off(self, time_period: timedelta): + """Set delayed turn off.""" + await self._try_command( + "Setting the turn off delay failed.", self._light.delay_off, time_period.total_seconds()) - @staticmethod - def translate(value, left_min, left_max, right_min, right_max): - """Map a value from left span to right span.""" - left_span = left_max - left_min - right_span = right_max - right_min - value_scaled = float(value - left_min) / float(left_span) - return int(right_min + (value_scaled * right_span)) - @staticmethod def delayed_turn_off_timestamp(countdown: int, current: datetime, previous: datetime): """Update the turn off timestamp only if necessary.""" - if countdown > 0: + if countdown is not None and countdown > 0: new = current.replace(microsecond=0) + \ timedelta(seconds=countdown) if previous is None: return new - lower = timedelta(seconds=-DELAYED_TURN_OFF_MAX_DEVIATION) - upper = timedelta(seconds=DELAYED_TURN_OFF_MAX_DEVIATION) + lower = timedelta(seconds=-DELAYED_TURN_OFF_MAX_DEVIATION_SECONDS) + upper = timedelta(seconds=DELAYED_TURN_OFF_MAX_DEVIATION_SECONDS) diff = previous - new if lower < diff < upper: return previous @@ -324,8 +382,14 @@ class XiaomiPhilipsGenericLight(Light): return None -class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): - """Representation of a Xiaomi Philips Light Ball.""" +class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): + """Representation of a Xiaomi Philips Bulb.""" + + def __init__(self, name, light, model, unique_id): + """Initialize the light device.""" + super().__init__(name, light, model, unique_id) + + self._color_temp = None @property def color_temp(self): @@ -347,8 +411,7 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): """Return the supported features.""" return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the light on.""" if ATTR_COLOR_TEMP in kwargs: color_temp = kwargs[ATTR_COLOR_TEMP] @@ -367,7 +430,7 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): brightness, percent_brightness, color_temp, percent_color_temp) - result = yield from self._try_command( + result = await self._try_command( "Setting brightness and color temperature failed: " "%s bri, %s cct", self._light.set_brightness_and_color_temperature, @@ -383,7 +446,7 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): "%s mireds, %s%% cct", color_temp, percent_color_temp) - result = yield from self._try_command( + result = await self._try_command( "Setting color temperature failed: %s cct", self._light.set_color_temperature, percent_color_temp) @@ -398,7 +461,7 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): "Setting brightness: %s %s%%", brightness, percent_brightness) - result = yield from self._try_command( + result = await self._try_command( "Setting brightness failed: %s", self._light.set_brightness, percent_brightness) @@ -406,17 +469,17 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): self._brightness = brightness else: - yield from self._try_command( + await self._try_command( "Turning the light on failed.", self._light.on) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Fetch state from the device.""" from miio import DeviceException try: - state = yield from self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_job(self._light.status) _LOGGER.debug("Got new state: %s", state) + self._available = True self._state = state.is_on self._brightness = ceil((255 / 100.0) * state.brightness) self._color_temp = self.translate( @@ -435,13 +498,30 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): }) except DeviceException as ex: - self._state = None + self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) + @staticmethod + def translate(value, left_min, left_max, right_min, right_max): + """Map a value from left span to right span.""" + left_span = left_max - left_min + right_span = right_max - right_min + value_scaled = float(value - left_min) / float(left_span) + return int(right_min + (value_scaled * right_span)) -class XiaomiPhilipsCeilingLamp(XiaomiPhilipsLightBall, Light): + +class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): """Representation of a Xiaomi Philips Ceiling Lamp.""" + def __init__(self, name, light, model, unique_id): + """Initialize the light device.""" + super().__init__(name, light, model, unique_id) + + self._state_attrs.update({ + ATTR_NIGHT_LIGHT_MODE: None, + ATTR_AUTOMATIC_COLOR_TEMPERATURE: None, + }) + @property def min_mireds(self): """Return the coldest color_temp that this light supports.""" @@ -452,8 +532,191 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsLightBall, Light): """Return the warmest color_temp that this light supports.""" return 370 + async def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + try: + state = await self.hass.async_add_job(self._light.status) + _LOGGER.debug("Got new state: %s", state) -class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight, Light): + self._available = True + self._state = state.is_on + self._brightness = ceil((255 / 100.0) * state.brightness) + self._color_temp = self.translate( + state.color_temperature, + CCT_MIN, CCT_MAX, + self.max_mireds, self.min_mireds) + + delayed_turn_off = self.delayed_turn_off_timestamp( + state.delay_off_countdown, + dt.utcnow(), + self._state_attrs[ATTR_DELAYED_TURN_OFF]) + + self._state_attrs.update({ + ATTR_SCENE: state.scene, + ATTR_DELAYED_TURN_OFF: delayed_turn_off, + ATTR_NIGHT_LIGHT_MODE: state.smart_night_light, + ATTR_AUTOMATIC_COLOR_TEMPERATURE: + state.automatic_color_temperature, + }) + + except DeviceException as ex: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) + + +class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight): """Representation of a Xiaomi Philips Eyecare Lamp 2.""" - pass + def __init__(self, name, light, model, unique_id): + """Initialize the light device.""" + super().__init__(name, light, model, unique_id) + + self._state_attrs.update({ + ATTR_REMINDER: None, + ATTR_NIGHT_LIGHT_MODE: None, + ATTR_EYECARE_MODE: None, + }) + + async def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + try: + state = await self.hass.async_add_job(self._light.status) + _LOGGER.debug("Got new state: %s", state) + + self._available = True + self._state = state.is_on + self._brightness = ceil((255 / 100.0) * state.brightness) + + delayed_turn_off = self.delayed_turn_off_timestamp( + state.delay_off_countdown, + dt.utcnow(), + self._state_attrs[ATTR_DELAYED_TURN_OFF]) + + self._state_attrs.update({ + ATTR_SCENE: state.scene, + ATTR_DELAYED_TURN_OFF: delayed_turn_off, + ATTR_REMINDER: state.reminder, + ATTR_NIGHT_LIGHT_MODE: state.smart_night_light, + ATTR_EYECARE_MODE: state.eyecare, + }) + + except DeviceException as ex: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) + + async def async_set_delayed_turn_off(self, time_period: timedelta): + """Set delayed turn off.""" + await self._try_command( + "Setting the turn off delay failed.", + self._light.delay_off, round(time_period.total_seconds() / 60)) + + async def async_reminder_on(self): + """Enable the eye fatigue notification.""" + await self._try_command( + "Turning on the reminder failed.", + self._light.reminder_on) + + async def async_reminder_off(self): + """Disable the eye fatigue notification.""" + await self._try_command( + "Turning off the reminder failed.", + self._light.reminder_off) + + async def async_night_light_mode_on(self): + """Turn the smart night light mode on.""" + await self._try_command( + "Turning on the smart night light mode failed.", + self._light.smart_night_light_on) + + async def async_night_light_mode_off(self): + """Turn the smart night light mode off.""" + await self._try_command( + "Turning off the smart night light mode failed.", + self._light.smart_night_light_off) + + async def async_eyecare_mode_on(self): + """Turn the eyecare mode on.""" + await self._try_command( + "Turning on the eyecare mode failed.", + self._light.eyecare_on) + + async def async_eyecare_mode_off(self): + """Turn the eyecare mode off.""" + await self._try_command( + "Turning off the eyecare mode failed.", + self._light.eyecare_off) + + @staticmethod + def delayed_turn_off_timestamp(countdown: int, + current: datetime, + previous: datetime): + """Update the turn off timestamp only if necessary.""" + if countdown is not None and countdown > 0: + new = current.replace(second=0, microsecond=0) + \ + timedelta(minutes=countdown) + + if previous is None: + return new + + lower = timedelta(minutes=-DELAYED_TURN_OFF_MAX_DEVIATION_MINUTES) + upper = timedelta(minutes=DELAYED_TURN_OFF_MAX_DEVIATION_MINUTES) + diff = previous - new + if lower < diff < upper: + return previous + + return new + + return None + + +class XiaomiPhilipsEyecareLampAmbientLight(XiaomiPhilipsAbstractLight): + """Representation of a Xiaomi Philips Eyecare Lamp Ambient Light.""" + + def __init__(self, name, light, model, unique_id): + """Initialize the light device.""" + name = '{} Ambient Light'.format(name) + if unique_id is not None: + unique_id = "{}-{}".format(unique_id, 'ambient') + super().__init__(name, light, model, unique_id) + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + percent_brightness = ceil(100 * brightness / 255.0) + + _LOGGER.debug( + "Setting brightness of the ambient light: %s %s%%", + brightness, percent_brightness) + + result = await self._try_command( + "Setting brightness of the ambient failed: %s", + self._light.set_ambient_brightness, percent_brightness) + + if result: + self._brightness = brightness + else: + await self._try_command( + "Turning the ambient light on failed.", self._light.ambient_on) + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + await self._try_command( + "Turning the ambient light off failed.", self._light.ambient_off) + + async def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + try: + state = await self.hass.async_add_job(self._light.status) + _LOGGER.debug("Got new state: %s", state) + + self._available = True + self._state = state.eyecare + self._brightness = ceil((255 / 100.0) * state.ambient_brightness) + + except DeviceException as ex: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index ca10d246ce8..202c6ac594d 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -5,41 +5,44 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.yeelight/ """ import logging -import colorsys -from typing import Tuple import voluptuous as vol from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, - color_temperature_kelvin_to_mired as kelvin_to_mired, - color_temperature_to_rgb, - color_RGB_to_xy, - color_xy_brightness_to_RGB) + color_temperature_kelvin_to_mired as kelvin_to_mired) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_COLOR_TEMP, - ATTR_FLASH, ATTR_XY_COLOR, FLASH_SHORT, FLASH_LONG, ATTR_EFFECT, - SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_XY_COLOR, - SUPPORT_TRANSITION, - SUPPORT_COLOR_TEMP, SUPPORT_FLASH, SUPPORT_EFFECT, - Light, PLATFORM_SCHEMA) + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_COLOR_TEMP, + ATTR_FLASH, FLASH_SHORT, FLASH_LONG, ATTR_EFFECT, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, SUPPORT_TRANSITION, SUPPORT_COLOR_TEMP, SUPPORT_FLASH, + SUPPORT_EFFECT, Light, PLATFORM_SCHEMA, ATTR_ENTITY_ID, DOMAIN) import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util REQUIREMENTS = ['yeelight==0.4.0'] _LOGGER = logging.getLogger(__name__) -CONF_TRANSITION = 'transition' +LEGACY_DEVICE_TYPE_MAP = { + 'color1': 'rgb', + 'mono1': 'white', + 'strip1': 'strip', + 'bslamp1': 'bedside', + 'ceiling1': 'ceiling', +} + +DEFAULT_NAME = 'Yeelight' DEFAULT_TRANSITION = 350 +CONF_TRANSITION = 'transition' CONF_SAVE_ON_CHANGE = 'save_on_change' CONF_MODE_MUSIC = 'use_music_mode' -DOMAIN = 'yeelight' +DATA_KEY = 'light.yeelight' DEVICE_SCHEMA = vol.Schema({ - vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.positive_int, vol.Optional(CONF_MODE_MUSIC, default=False): cv.boolean, vol.Optional(CONF_SAVE_ON_CHANGE, default=True): cv.boolean, @@ -53,8 +56,7 @@ SUPPORT_YEELIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_FLASH) SUPPORT_YEELIGHT_RGB = (SUPPORT_YEELIGHT | - SUPPORT_RGB_COLOR | - SUPPORT_XY_COLOR | + SUPPORT_COLOR | SUPPORT_EFFECT | SUPPORT_COLOR_TEMP) @@ -97,13 +99,12 @@ YEELIGHT_EFFECT_LIST = [ EFFECT_TWITTER, EFFECT_STOP] +SERVICE_SET_MODE = 'yeelight_set_mode' +ATTR_MODE = 'mode' -# Travis-CI runs too old astroid https://github.com/PyCQA/pylint/issues/1212 -# pylint: disable=invalid-sequence-index -def hsv_to_rgb(hsv: Tuple[float, float, float]) -> Tuple[int, int, int]: - """Convert HSV tuple (degrees, %, %) to RGB (values 0-255).""" - red, green, blue = colorsys.hsv_to_rgb(hsv[0]/360, hsv[1]/100, hsv[2]/100) - return int(red * 255), int(green * 255), int(blue * 255) +YEELIGHT_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) def _cmd(func): @@ -121,25 +122,59 @@ def _cmd(func): def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Yeelight bulbs.""" + from yeelight.enums import PowerMode + + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} + lights = [] if discovery_info is not None: _LOGGER.debug("Adding autodetected %s", discovery_info['hostname']) + device_type = discovery_info['device_type'] + device_type = LEGACY_DEVICE_TYPE_MAP.get(device_type, device_type) + # Not using hostname, as it seems to vary. - name = "yeelight_%s_%s" % (discovery_info['device_type'], + name = "yeelight_%s_%s" % (device_type, discovery_info['properties']['mac']) - device = {'name': name, 'ipaddr': discovery_info['host']} + host = discovery_info['host'] + device = {'name': name, 'ipaddr': host} - lights.append(YeelightLight(device, DEVICE_SCHEMA({}))) + light = YeelightLight(device, DEVICE_SCHEMA({})) + lights.append(light) + hass.data[DATA_KEY][host] = light else: - for ipaddr, device_config in config[CONF_DEVICES].items(): - _LOGGER.debug("Adding configured %s", device_config[CONF_NAME]) - - device = {'name': device_config[CONF_NAME], 'ipaddr': ipaddr} - lights.append(YeelightLight(device, device_config)) + for host, device_config in config[CONF_DEVICES].items(): + device = {'name': device_config[CONF_NAME], 'ipaddr': host} + light = YeelightLight(device, device_config) + lights.append(light) + hass.data[DATA_KEY][host] = light add_devices(lights, True) + def service_handler(service): + """Dispatch service calls to target entities.""" + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + target_devices = [dev for dev in hass.data[DATA_KEY].values() + if dev.entity_id in entity_ids] + else: + target_devices = hass.data[DATA_KEY].values() + + for target_device in target_devices: + if service.service == SERVICE_SET_MODE: + target_device.set_mode(**params) + + service_schema_set_mode = YEELIGHT_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_MODE): + vol.In([mode.name.lower() for mode in PowerMode]) + }) + hass.services.register( + DOMAIN, SERVICE_SET_MODE, service_handler, + schema=service_schema_set_mode) + class YeelightLight(Light): """Representation of a Yeelight light.""" @@ -157,8 +192,7 @@ class YeelightLight(Light): self._brightness = None self._color_temp = None self._is_on = None - self._rgb = None - self._xy = None + self._hs = None @property def available(self) -> bool: @@ -209,38 +243,32 @@ class YeelightLight(Light): return kelvin_to_mired(YEELIGHT_RGB_MIN_KELVIN) return kelvin_to_mired(YEELIGHT_MIN_KELVIN) - def _get_rgb_from_properties(self): + def _get_hs_from_properties(self): rgb = self._properties.get('rgb', None) color_mode = self._properties.get('color_mode', None) if not rgb or not color_mode: - return rgb + return None color_mode = int(color_mode) if color_mode == 2: # color temperature temp_in_k = mired_to_kelvin(self._color_temp) - return color_temperature_to_rgb(temp_in_k) + return color_util.color_temperature_to_hs(temp_in_k) if color_mode == 3: # hsv hue = int(self._properties.get('hue')) sat = int(self._properties.get('sat')) - val = int(self._properties.get('bright')) - return hsv_to_rgb((hue, sat, val)) + return (hue / 360 * 65536, sat / 100 * 255) rgb = int(rgb) blue = rgb & 0xff green = (rgb >> 8) & 0xff red = (rgb >> 16) & 0xff - return red, green, blue + return color_util.color_RGB_to_hs(red, green, blue) @property - def rgb_color(self) -> tuple: + def hs_color(self) -> tuple: """Return the color property.""" - return self._rgb - - @property - def xy_color(self) -> tuple: - """Return the XY color value.""" - return self._xy + return self._hs @property def _properties(self) -> dict: @@ -288,13 +316,7 @@ class YeelightLight(Light): if temp_in_k: self._color_temp = kelvin_to_mired(int(temp_in_k)) - self._rgb = self._get_rgb_from_properties() - - if self._rgb: - xyb = color_RGB_to_xy(*self._rgb) - self._xy = (xyb[0], xyb[1]) - else: - self._xy = None + self._hs = self._get_hs_from_properties() self._available = True except yeelight.BulbException as ex: @@ -313,7 +335,7 @@ class YeelightLight(Light): @_cmd def set_rgb(self, rgb, duration) -> None: """Set bulb's color.""" - if rgb and self.supported_features & SUPPORT_RGB_COLOR: + if rgb and self.supported_features & SUPPORT_COLOR: _LOGGER.debug("Setting RGB: %s", rgb) self._bulb.set_rgb(rgb[0], rgb[1], rgb[2], duration=duration) @@ -349,7 +371,7 @@ class YeelightLight(Light): count = 1 duration = transition * 2 - red, green, blue = self.rgb_color + red, green, blue = color_util.color_hs_to_RGB(*self._hs) transitions = list() transitions.append( @@ -419,10 +441,10 @@ class YeelightLight(Light): import yeelight brightness = kwargs.get(ATTR_BRIGHTNESS) colortemp = kwargs.get(ATTR_COLOR_TEMP) - rgb = kwargs.get(ATTR_RGB_COLOR) + hs_color = kwargs.get(ATTR_HS_COLOR) + rgb = color_util.color_hs_to_RGB(*hs_color) if hs_color else None flash = kwargs.get(ATTR_FLASH) effect = kwargs.get(ATTR_EFFECT) - xy_color = kwargs.get(ATTR_XY_COLOR) duration = int(self.config[CONF_TRANSITION]) # in ms if ATTR_TRANSITION in kwargs: # passed kwarg overrides config @@ -440,9 +462,6 @@ class YeelightLight(Light): except yeelight.BulbException as ex: _LOGGER.error("Unable to turn on music mode," "consider disabling it: %s", ex) - if xy_color and brightness: - rgb = color_xy_brightness_to_RGB(xy_color[0], xy_color[1], - brightness) try: # values checked for none in methods @@ -475,3 +494,11 @@ class YeelightLight(Light): self._bulb.turn_off(duration=duration) except yeelight.BulbException as ex: _LOGGER.error("Unable to turn the bulb off: %s", ex) + + def set_mode(self, mode: str): + """Set a power mode.""" + import yeelight + try: + self._bulb.set_power_mode(yeelight.enums.PowerMode[mode.upper()]) + except yeelight.BulbException as ex: + _LOGGER.error("Unable to set the power mode: %s", ex) diff --git a/homeassistant/components/light/yeelightsunflower.py b/homeassistant/components/light/yeelightsunflower.py index 5f48e3a0a71..96cce67b1bb 100644 --- a/homeassistant/components/light/yeelightsunflower.py +++ b/homeassistant/components/light/yeelightsunflower.py @@ -10,15 +10,16 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.light import ( - Light, ATTR_RGB_COLOR, SUPPORT_RGB_COLOR, ATTR_BRIGHTNESS, + Light, ATTR_HS_COLOR, SUPPORT_COLOR, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, PLATFORM_SCHEMA) from homeassistant.const import CONF_HOST +import homeassistant.util.color as color_util -REQUIREMENTS = ['yeelightsunflower==0.0.8'] +REQUIREMENTS = ['yeelightsunflower==0.0.10'] _LOGGER = logging.getLogger(__name__) -SUPPORT_YEELIGHT_SUNFLOWER = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR) +SUPPORT_YEELIGHT_SUNFLOWER = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string @@ -48,7 +49,7 @@ class SunflowerBulb(Light): self._available = light.available self._brightness = light.brightness self._is_on = light.is_on - self._rgb_color = light.rgb_color + self._hs_color = light.rgb_color @property def name(self): @@ -71,9 +72,9 @@ class SunflowerBulb(Light): return int(self._brightness / 100 * 255) @property - def rgb_color(self): + def hs_color(self): """Return the color property.""" - return self._rgb_color + return self._hs_color @property def supported_features(self): @@ -86,12 +87,12 @@ class SunflowerBulb(Light): if not kwargs: self._light.turn_on() else: - if ATTR_RGB_COLOR in kwargs and ATTR_BRIGHTNESS in kwargs: - rgb = kwargs[ATTR_RGB_COLOR] + if ATTR_HS_COLOR in kwargs and ATTR_BRIGHTNESS in kwargs: + rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) bright = int(kwargs[ATTR_BRIGHTNESS] / 255 * 100) self._light.set_all(rgb[0], rgb[1], rgb[2], bright) - elif ATTR_RGB_COLOR in kwargs: - rgb = kwargs[ATTR_RGB_COLOR] + elif ATTR_HS_COLOR in kwargs: + rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) self._light.set_rgb_color(rgb[0], rgb[1], rgb[2]) elif ATTR_BRIGHTNESS in kwargs: bright = int(kwargs[ATTR_BRIGHTNESS] / 255 * 100) @@ -107,4 +108,4 @@ class SunflowerBulb(Light): self._available = self._light.available self._brightness = self._light.brightness self._is_on = self._light.is_on - self._rgb_color = self._light.rgb_color + self._hs_color = color_util.color_RGB_to_hs(*self._light.rgb_color) diff --git a/homeassistant/components/light/zengge.py b/homeassistant/components/light/zengge.py index 7071c8c43bb..3c77f2d8449 100644 --- a/homeassistant/components/light/zengge.py +++ b/homeassistant/components/light/zengge.py @@ -10,15 +10,16 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.components.light import ( - ATTR_RGB_COLOR, ATTR_WHITE_VALUE, - SUPPORT_RGB_COLOR, SUPPORT_WHITE_VALUE, Light, PLATFORM_SCHEMA) + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, SUPPORT_WHITE_VALUE, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util REQUIREMENTS = ['zengge==0.2'] _LOGGER = logging.getLogger(__name__) -SUPPORT_ZENGGE_LED = (SUPPORT_RGB_COLOR | SUPPORT_WHITE_VALUE) +SUPPORT_ZENGGE_LED = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_WHITE_VALUE) DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, @@ -56,7 +57,8 @@ class ZenggeLight(Light): self.is_valid = True self._bulb = zengge.zengge(self._address) self._white = 0 - self._rgb = (0, 0, 0) + self._brightness = 0 + self._hs_color = (0, 0) self._state = False if self._bulb.connect() is False: self.is_valid = False @@ -80,9 +82,14 @@ class ZenggeLight(Light): return self._state @property - def rgb_color(self): + def brightness(self): + """Return the brightness property.""" + return self._brightness + + @property + def hs_color(self): """Return the color property.""" - return self._rgb + return self._hs_color @property def white_value(self): @@ -117,21 +124,29 @@ class ZenggeLight(Light): self._state = True self._bulb.on() - rgb = kwargs.get(ATTR_RGB_COLOR) + hs_color = kwargs.get(ATTR_HS_COLOR) white = kwargs.get(ATTR_WHITE_VALUE) + brightness = kwargs.get(ATTR_BRIGHTNESS) if white is not None: self._white = white - self._rgb = (0, 0, 0) + self._hs_color = (0, 0) - if rgb is not None: + if hs_color is not None: self._white = 0 - self._rgb = rgb + self._hs_color = hs_color + + if brightness is not None: + self._white = 0 + self._brightness = brightness if self._white != 0: self.set_white(self._white) else: - self.set_rgb(self._rgb[0], self._rgb[1], self._rgb[2]) + rgb = color_util.color_hsv_to_RGB( + self._hs_color[0], self._hs_color[1], + self._brightness / 255 * 100) + self.set_rgb(*rgb) def turn_off(self, **kwargs): """Turn the specified light off.""" @@ -140,6 +155,9 @@ class ZenggeLight(Light): def update(self): """Synchronise internal state with the actual light state.""" - self._rgb = self._bulb.get_colour() + rgb = self._bulb.get_colour() + hsv = color_util.color_RGB_to_hsv(*rgb) + self._hs_color = hsv[:2] + self._brightness = hsv[2] self._white = self._bulb.get_white() self._state = self._bulb.get_on() diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 7958fcabf13..bd01a513e0b 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -5,10 +5,8 @@ For more details on this platform, please refer to the documentation at https://home-assistant.io/components/light.zha/ """ import logging - from homeassistant.components import light, zha -from homeassistant.util.color import color_RGB_to_xy -from homeassistant.const import STATE_UNKNOWN +import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -57,7 +55,7 @@ class Light(zha.Entity, light.Light): super().__init__(**kwargs) self._supported_features = 0 self._color_temp = None - self._xy_color = None + self._hs_color = None self._brightness = None import zigpy.zcl.clusters as zcl_clusters @@ -71,14 +69,13 @@ class Light(zha.Entity, light.Light): self._supported_features |= light.SUPPORT_COLOR_TEMP if color_capabilities & CAPABILITIES_COLOR_XY: - self._supported_features |= light.SUPPORT_XY_COLOR - self._supported_features |= light.SUPPORT_RGB_COLOR - self._xy_color = (1.0, 1.0) + self._supported_features |= light.SUPPORT_COLOR + self._hs_color = (0, 0) @property def is_on(self) -> bool: """Return true if entity is on.""" - if self._state == STATE_UNKNOWN: + if self._state is None: return False return bool(self._state) @@ -92,17 +89,12 @@ class Light(zha.Entity, light.Light): temperature, duration) self._color_temp = temperature - if light.ATTR_XY_COLOR in kwargs: - self._xy_color = kwargs[light.ATTR_XY_COLOR] - elif light.ATTR_RGB_COLOR in kwargs: - xyb = color_RGB_to_xy( - *(int(val) for val in kwargs[light.ATTR_RGB_COLOR])) - self._xy_color = (xyb[0], xyb[1]) - self._brightness = xyb[2] - if light.ATTR_XY_COLOR in kwargs or light.ATTR_RGB_COLOR in kwargs: + if light.ATTR_HS_COLOR in kwargs: + self._hs_color = kwargs[light.ATTR_HS_COLOR] + xy_color = color_util.color_hs_to_xy(*self._hs_color) await self._endpoint.light_color.move_to_color( - int(self._xy_color[0] * 65535), - int(self._xy_color[1] * 65535), + int(xy_color[0] * 65535), + int(xy_color[1] * 65535), duration, ) @@ -118,14 +110,25 @@ class Light(zha.Entity, light.Light): self._state = 1 self.async_schedule_update_ha_state() return + from zigpy.exceptions import DeliveryError + try: + await self._endpoint.on_off.on() + except DeliveryError as ex: + _LOGGER.error("Unable to turn the light on: %s", ex) + return - await self._endpoint.on_off.on() self._state = 1 self.async_schedule_update_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" - await self._endpoint.on_off.off() + from zigpy.exceptions import DeliveryError + try: + await self._endpoint.on_off.off() + except DeliveryError as ex: + _LOGGER.error("Unable to turn the light off: %s", ex) + return + self._state = 0 self.async_schedule_update_ha_state() @@ -135,9 +138,9 @@ class Light(zha.Entity, light.Light): return self._brightness @property - def xy_color(self): - """Return the XY color value [float, float].""" - return self._xy_color + def hs_color(self): + """Return the hs color value [int, int].""" + return self._hs_color @property def color_temp(self): @@ -165,11 +168,13 @@ class Light(zha.Entity, light.Light): self._color_temp = result.get('color_temperature', self._color_temp) - if self._supported_features & light.SUPPORT_XY_COLOR: + if self._supported_features & light.SUPPORT_COLOR: result = await zha.safe_read(self._endpoint.light_color, ['current_x', 'current_y']) if 'current_x' in result and 'current_y' in result: - self._xy_color = (result['current_x'], result['current_y']) + xy_color = (round(result['current_x']/65535, 3), + round(result['current_y']/65535, 3)) + self._hs_color = color_util.color_xy_to_hs(*xy_color) @property def should_poll(self) -> bool: diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index 64c6530dd2b..04216780c80 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -9,14 +9,14 @@ import logging # Because we do not compile openzwave on CI # pylint: disable=import-error from threading import Timer -from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, \ - ATTR_RGB_COLOR, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, \ - SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, DOMAIN, Light +from homeassistant.components.light import ( + ATTR_WHITE_VALUE, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, + SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, DOMAIN, Light) from homeassistant.components import zwave from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.util.color import color_temperature_mired_to_kelvin, \ - color_temperature_to_rgb, color_rgb_to_rgbw, color_rgbw_to_rgb +import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -61,14 +61,15 @@ def get_device(node, values, node_config, **kwargs): def brightness_state(value): """Return the brightness and state.""" if value.data > 0: - return round((value.data / 99) * 255, 0), STATE_ON + return round((value.data / 99) * 255), STATE_ON return 0, STATE_OFF -def ct_to_rgb(temp): - """Convert color temperature (mireds) to RGB.""" +def ct_to_hs(temp): + """Convert color temperature (mireds) to hs.""" colorlist = list( - color_temperature_to_rgb(color_temperature_mired_to_kelvin(temp))) + color_util.color_temperature_to_hs( + color_util.color_temperature_mired_to_kelvin(temp))) return [int(val) for val in colorlist] @@ -209,8 +210,9 @@ class ZwaveColorLight(ZwaveDimmer): def __init__(self, values, refresh, delay): """Initialize the light.""" self._color_channels = None - self._rgb = None + self._hs = None self._ct = None + self._white = None super().__init__(values, refresh, delay) @@ -218,9 +220,12 @@ class ZwaveColorLight(ZwaveDimmer): """Call when a new value is added to this entity.""" super().value_added() - self._supported_features |= SUPPORT_RGB_COLOR + self._supported_features |= SUPPORT_COLOR if self._zw098: self._supported_features |= SUPPORT_COLOR_TEMP + elif self._color_channels is not None and self._color_channels & ( + COLOR_CHANNEL_WARM_WHITE | COLOR_CHANNEL_COLD_WHITE): + self._supported_features |= SUPPORT_WHITE_VALUE def update_properties(self): """Update internal properties based on zwave values.""" @@ -238,10 +243,11 @@ class ZwaveColorLight(ZwaveDimmer): data = self.values.color.data # RGB is always present in the openzwave color data string. - self._rgb = [ + rgb = [ int(data[1:3], 16), int(data[3:5], 16), int(data[5:7], 16)] + self._hs = color_util.color_RGB_to_hs(*rgb) # Parse remaining color channels. Openzwave appends white channels # that are present. @@ -267,30 +273,35 @@ class ZwaveColorLight(ZwaveDimmer): if self._zw098: if warm_white > 0: self._ct = TEMP_WARM_HASS - self._rgb = ct_to_rgb(self._ct) + self._hs = ct_to_hs(self._ct) elif cold_white > 0: self._ct = TEMP_COLD_HASS - self._rgb = ct_to_rgb(self._ct) + self._hs = ct_to_hs(self._ct) else: # RGB color is being used. Just report midpoint. self._ct = TEMP_MID_HASS elif self._color_channels & COLOR_CHANNEL_WARM_WHITE: - self._rgb = list(color_rgbw_to_rgb(*self._rgb, w=warm_white)) + self._white = warm_white elif self._color_channels & COLOR_CHANNEL_COLD_WHITE: - self._rgb = list(color_rgbw_to_rgb(*self._rgb, w=cold_white)) + self._white = cold_white # If no rgb channels supported, report None. if not (self._color_channels & COLOR_CHANNEL_RED or self._color_channels & COLOR_CHANNEL_GREEN or self._color_channels & COLOR_CHANNEL_BLUE): - self._rgb = None + self._hs = None @property - def rgb_color(self): - """Return the rgb color.""" - return self._rgb + def hs_color(self): + """Return the hs color.""" + return self._hs + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return self._white @property def color_temp(self): @@ -301,6 +312,9 @@ class ZwaveColorLight(ZwaveDimmer): """Turn the device on.""" rgbw = None + if ATTR_WHITE_VALUE in kwargs: + self._white = kwargs[ATTR_WHITE_VALUE] + if ATTR_COLOR_TEMP in kwargs: # Color temperature. With the AEOTEC ZW098 bulb, only two color # temperatures are supported. The warm and cold channel values @@ -313,19 +327,16 @@ class ZwaveColorLight(ZwaveDimmer): self._ct = TEMP_COLD_HASS rgbw = '#00000000ff' - elif ATTR_RGB_COLOR in kwargs: - self._rgb = kwargs[ATTR_RGB_COLOR] - if (not self._zw098 and ( - self._color_channels & COLOR_CHANNEL_WARM_WHITE or - self._color_channels & COLOR_CHANNEL_COLD_WHITE)): - rgbw = '#' - for colorval in color_rgb_to_rgbw(*self._rgb): - rgbw += format(colorval, '02x') - rgbw += '00' + elif ATTR_HS_COLOR in kwargs: + self._hs = kwargs[ATTR_HS_COLOR] + + if ATTR_WHITE_VALUE in kwargs or ATTR_HS_COLOR in kwargs: + rgbw = '#' + for colorval in color_util.color_hs_to_RGB(*self._hs): + rgbw += format(colorval, '02x') + if self._white is not None: + rgbw += format(self._white, '02x') + '00' else: - rgbw = '#' - for colorval in self._rgb: - rgbw += format(colorval, '02x') rgbw += '0000' if rgbw and self.values.color: diff --git a/homeassistant/components/linode.py b/homeassistant/components/linode.py index 9e87c002482..962e30774b8 100644 --- a/homeassistant/components/linode.py +++ b/homeassistant/components/linode.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['linode-api==4.1.4b2'] +REQUIREMENTS = ['linode-api==4.1.9b1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index d03bbebd696..b3e4ac8f0ff 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, - STATE_UNKNOWN, SERVICE_LOCK, SERVICE_UNLOCK) + STATE_UNKNOWN, SERVICE_LOCK, SERVICE_UNLOCK, SERVICE_OPEN) from homeassistant.components import group ATTR_CHANGED_BY = 'changed_by' @@ -39,6 +39,9 @@ LOCK_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_CODE): cv.string, }) +# Bitfield of features supported by the lock entity +SUPPORT_OPEN = 1 + _LOGGER = logging.getLogger(__name__) PROP_TO_ATTR = { @@ -78,6 +81,18 @@ def unlock(hass, entity_id=None, code=None): hass.services.call(DOMAIN, SERVICE_UNLOCK, data) +@bind_hass +def open_lock(hass, entity_id=None, code=None): + """Open all or specified locks.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_OPEN, data) + + @asyncio.coroutine def async_setup(hass, config): """Track states and offer events for locks.""" @@ -97,6 +112,8 @@ def async_setup(hass, config): for entity in target_locks: if service.service == SERVICE_LOCK: yield from entity.async_lock(code=code) + elif service.service == SERVICE_OPEN: + yield from entity.async_open(code=code) else: yield from entity.async_unlock(code=code) @@ -113,6 +130,9 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_LOCK, async_handle_lock_service, schema=LOCK_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_OPEN, async_handle_lock_service, + schema=LOCK_SERVICE_SCHEMA) return True @@ -158,6 +178,17 @@ class LockDevice(Entity): """ return self.hass.async_add_job(ft.partial(self.unlock, **kwargs)) + def open(self, **kwargs): + """Open the door latch.""" + raise NotImplementedError() + + def async_open(self, **kwargs): + """Open the door latch. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(ft.partial(self.open, **kwargs)) + @property def state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/lock/bmw_connected_drive.py b/homeassistant/components/lock/bmw_connected_drive.py new file mode 100644 index 00000000000..52734b1259c --- /dev/null +++ b/homeassistant/components/lock/bmw_connected_drive.py @@ -0,0 +1,119 @@ +""" +Support for BMW cars with BMW ConnectedDrive. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/lock.bmw_connected_drive/ +""" +import asyncio +import logging + +from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.components.lock import LockDevice +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED + +DEPENDENCIES = ['bmw_connected_drive'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the BMW Connected Drive lock.""" + 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: + device = BMWLock(account, vehicle, 'lock', 'BMW lock') + devices.append(device) + add_devices(devices, True) + + +class BMWLock(LockDevice): + """Representation of a BMW vehicle lock.""" + + def __init__(self, account, vehicle, attribute: str, sensor_name): + """Initialize the lock.""" + 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._state = None + + @property + def should_poll(self): + """Do not poll this class. + + Updates are triggered from BMWConnectedDriveAccount. + """ + return False + + @property + def unique_id(self): + """Return the unique ID of the lock.""" + return self._unique_id + + @property + def name(self): + """Return the name of the lock.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the lock.""" + vehicle_state = self._vehicle.state + return { + 'car': self._vehicle.name, + 'door_lock_state': vehicle_state.door_lock_state.value + } + + @property + def is_locked(self): + """Return true if lock is locked.""" + return self._state == STATE_LOCKED + + def lock(self, **kwargs): + """Lock the car.""" + _LOGGER.debug("%s: locking doors", self._vehicle.name) + # Optimistic state set here because it takes some time before the + # update callback response + self._state = STATE_LOCKED + self.schedule_update_ha_state() + self._vehicle.remote_services.trigger_remote_door_lock() + + def unlock(self, **kwargs): + """Unlock the car.""" + _LOGGER.debug("%s: unlocking doors", self._vehicle.name) + # Optimistic state set here because it takes some time before the + # update callback response + self._state = STATE_UNLOCKED + self.schedule_update_ha_state() + self._vehicle.remote_services.trigger_remote_door_unlock() + + def update(self): + """Update state of the lock.""" + from bimmer_connected.state import LockState + + _LOGGER.debug("%s: updating data for %s", self._vehicle.name, + self._attribute) + vehicle_state = self._vehicle.state + + # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED + self._state = STATE_LOCKED \ + if vehicle_state.door_lock_state \ + in [LockState.LOCKED, LockState.SECURED] \ + else STATE_UNLOCKED + + 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) diff --git a/homeassistant/components/lock/demo.py b/homeassistant/components/lock/demo.py index aca25e7e16d..d561dd333ab 100644 --- a/homeassistant/components/lock/demo.py +++ b/homeassistant/components/lock/demo.py @@ -4,7 +4,7 @@ Demo lock platform that has two fake locks. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ -from homeassistant.components.lock import LockDevice +from homeassistant.components.lock import LockDevice, SUPPORT_OPEN from homeassistant.const import (STATE_LOCKED, STATE_UNLOCKED) @@ -13,17 +13,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo lock platform.""" add_devices([ DemoLock('Front Door', STATE_LOCKED), - DemoLock('Kitchen Door', STATE_UNLOCKED) + DemoLock('Kitchen Door', STATE_UNLOCKED), + DemoLock('Openable Lock', STATE_LOCKED, True) ]) class DemoLock(LockDevice): """Representation of a Demo lock.""" - def __init__(self, name, state): + def __init__(self, name, state, openable=False): """Initialize the lock.""" self._name = name self._state = state + self._openable = openable @property def should_poll(self): @@ -49,3 +51,14 @@ class DemoLock(LockDevice): """Unlock the device.""" self._state = STATE_UNLOCKED self.schedule_update_ha_state() + + def open(self, **kwargs): + """Open the door latch.""" + self._state = STATE_UNLOCKED + self.schedule_update_ha_state() + + @property + def supported_features(self): + """Flag supported features.""" + if self._openable: + return SUPPORT_OPEN diff --git a/homeassistant/components/lock/homematic.py b/homeassistant/components/lock/homematic.py new file mode 100644 index 00000000000..0d70849e37e --- /dev/null +++ b/homeassistant/components/lock/homematic.py @@ -0,0 +1,58 @@ +""" +Support for Homematic lock. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.homematic/ +""" +import logging +from homeassistant.components.lock import LockDevice, SUPPORT_OPEN +from homeassistant.components.homematic import HMDevice, ATTR_DISCOVER_DEVICES +from homeassistant.const import STATE_UNKNOWN + + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['homematic'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Homematic lock platform.""" + if discovery_info is None: + return + + devices = [] + for conf in discovery_info[ATTR_DISCOVER_DEVICES]: + devices.append(HMLock(conf)) + + add_devices(devices) + + +class HMLock(HMDevice, LockDevice): + """Representation of a Homematic lock aka KeyMatic.""" + + @property + def is_locked(self): + """Return true if the lock is locked.""" + return not bool(self._hm_get_state()) + + def lock(self, **kwargs): + """Lock the lock.""" + self._hmdevice.lock() + + def unlock(self, **kwargs): + """Unlock the lock.""" + self._hmdevice.unlock() + + def open(self, **kwargs): + """Open the door latch.""" + self._hmdevice.open() + + def _init_data_struct(self): + """Generate the data dictionary (self._data) from metadata.""" + self._state = "STATE" + self._data.update({self._state: STATE_UNKNOWN}) + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index e73e35a9900..d8af22cd5c3 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -44,6 +44,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the MQTT lock.""" + if discovery_info is not None: + config = PLATFORM_SCHEMA(discovery_info) + value_template = config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass diff --git a/homeassistant/components/lock/wink.py b/homeassistant/components/lock/wink.py index a5cd18454df..1c42e427a00 100644 --- a/homeassistant/components/lock/wink.py +++ b/homeassistant/components/lock/wink.py @@ -11,7 +11,8 @@ import voluptuous as vol from homeassistant.components.lock import LockDevice from homeassistant.components.wink import DOMAIN, WinkDevice -from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_CODE, ATTR_ENTITY_ID, ATTR_NAME, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['wink'] @@ -28,7 +29,6 @@ SERVICE_ADD_KEY = 'wink_add_new_lock_key_code' ATTR_ENABLED = 'enabled' ATTR_SENSITIVITY = 'sensitivity' ATTR_MODE = 'mode' -ATTR_NAME = 'name' ALARM_SENSITIVITY_MAP = { 'low': 0.2, diff --git a/homeassistant/components/lock/xiaomi_aqara.py b/homeassistant/components/lock/xiaomi_aqara.py new file mode 100644 index 00000000000..9b084a2bc55 --- /dev/null +++ b/homeassistant/components/lock/xiaomi_aqara.py @@ -0,0 +1,92 @@ +""" +Support for Xiaomi Aqara Lock. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.xiaomi_aqara/ +""" +import logging +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) +from homeassistant.components.lock import LockDevice +from homeassistant.const import (STATE_LOCKED, STATE_UNLOCKED) +from homeassistant.helpers.event import async_call_later +from homeassistant.core import callback + +_LOGGER = logging.getLogger(__name__) + +FINGER_KEY = 'fing_verified' +PASSWORD_KEY = 'psw_verified' +CARD_KEY = 'card_verified' +VERIFIED_WRONG_KEY = 'verified_wrong' + +ATTR_VERIFIED_WRONG_TIMES = 'verified_wrong_times' + +UNLOCK_MAINTAIN_TIME = 5 + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Perform the setup for Xiaomi devices.""" + devices = [] + + for gateway in hass.data[PY_XIAOMI_GATEWAY].gateways.values(): + for device in gateway.devices['lock']: + model = device['model'] + if model == 'lock.aq1': + devices.append(XiaomiAqaraLock(device, 'Lock', gateway)) + async_add_devices(devices) + + +class XiaomiAqaraLock(LockDevice, XiaomiDevice): + """Representation of a XiaomiAqaraLock.""" + + def __init__(self, device, name, xiaomi_hub): + """Initialize the XiaomiAqaraLock.""" + self._changed_by = 0 + self._verified_wrong_times = 0 + + super().__init__(device, name, xiaomi_hub) + + @property + def is_locked(self) -> bool: + """Return true if lock is locked.""" + if self._state is not None: + return self._state == STATE_LOCKED + + @property + def changed_by(self) -> int: + """Last change triggered by.""" + return self._changed_by + + @property + def device_state_attributes(self) -> dict: + """Return the state attributes.""" + attributes = { + ATTR_VERIFIED_WRONG_TIMES: self._verified_wrong_times, + } + return attributes + + @callback + def clear_unlock_state(self, _): + """Clear unlock state automatically.""" + self._state = STATE_LOCKED + self.async_schedule_update_ha_state() + + def parse_data(self, data, raw_data): + """Parse data sent by gateway.""" + value = data.get(VERIFIED_WRONG_KEY) + if value is not None: + self._verified_wrong_times = int(value) + return True + + for key in (FINGER_KEY, PASSWORD_KEY, CARD_KEY): + value = data.get(key) + if value is not None: + self._changed_by = int(value) + self._verified_wrong_times = 0 + self._state = STATE_UNLOCKED + async_call_later(self.hass, UNLOCK_MAINTAIN_TIME, + self.clear_unlock_state) + return True + + return False diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index d0b944793c4..1ea0b586d33 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -4,45 +4,49 @@ Event parser and human readable log generator. For more details about this component, please refer to the documentation at https://home-assistant.io/components/logbook/ """ -import asyncio -import logging from datetime import timedelta from itertools import groupby +import logging import voluptuous as vol -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util from homeassistant.components import sun from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, - STATE_NOT_HOME, STATE_OFF, STATE_ON, ATTR_HIDDEN, HTTP_BAD_REQUEST, - EVENT_LOGBOOK_ENTRY) -from homeassistant.core import State, split_entity_id, DOMAIN as HA_DOMAIN - -DOMAIN = 'logbook' -DEPENDENCIES = ['recorder', 'frontend'] + ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_HIDDEN, ATTR_NAME, CONF_EXCLUDE, + CONF_INCLUDE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, HTTP_BAD_REQUEST, STATE_NOT_HOME, + STATE_OFF, STATE_ON) +from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.core import State, callback, split_entity_id +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -CONF_EXCLUDE = 'exclude' -CONF_INCLUDE = 'include' -CONF_ENTITIES = 'entities' +ATTR_MESSAGE = 'message' + CONF_DOMAINS = 'domains' +CONF_ENTITIES = 'entities' +CONTINUOUS_DOMAINS = ['proximity', 'sensor'] + +DEPENDENCIES = ['recorder', 'frontend'] + +DOMAIN = 'logbook' + +GROUP_BY_MINUTES = 15 CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ CONF_EXCLUDE: vol.Schema({ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): vol.All(cv.ensure_list, - [cv.string]) + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) }), CONF_INCLUDE: vol.Schema({ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): vol.All(cv.ensure_list, - [cv.string]) + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) }) }), }, extra=vol.ALLOW_EXTRA) @@ -52,15 +56,6 @@ ALL_EVENT_TYPES = [ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP ] -GROUP_BY_MINUTES = 15 - -CONTINUOUS_DOMAINS = ['proximity', 'sensor'] - -ATTR_NAME = 'name' -ATTR_MESSAGE = 'message' -ATTR_DOMAIN = 'domain' -ATTR_ENTITY_ID = 'entity_id' - LOG_MESSAGE_SCHEMA = vol.Schema({ vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_MESSAGE): cv.template, @@ -88,8 +83,7 @@ def async_log_entry(hass, name, message, domain=None, entity_id=None): hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, data) -@asyncio.coroutine -def setup(hass, config): +async def setup(hass, config): """Listen for download events to download files.""" @callback def log_message(service): @@ -105,7 +99,7 @@ def setup(hass, config): hass.http.register_view(LogbookView(config.get(DOMAIN, {}))) - yield from hass.components.frontend.async_register_built_in_panel( + await hass.components.frontend.async_register_built_in_panel( 'logbook', 'logbook', 'mdi:format-list-bulleted-type') hass.services.async_register( @@ -124,8 +118,7 @@ class LogbookView(HomeAssistantView): """Initialize the logbook view.""" self.config = config - @asyncio.coroutine - def get(self, request, datetime=None): + async def get(self, request, datetime=None): """Retrieve logbook entries.""" if datetime: datetime = dt_util.parse_datetime(datetime) @@ -139,10 +132,12 @@ class LogbookView(HomeAssistantView): end_day = start_day + timedelta(days=1) hass = request.app['hass'] - events = yield from hass.async_add_job( - _get_events, hass, self.config, start_day, end_day) - response = yield from hass.async_add_job(self.json, events) - return response + def json_events(): + """Fetch events and generate JSON.""" + return self.json(list( + _get_events(hass, self.config, start_day, end_day))) + + return await hass.async_add_job(json_events) class Entry(object): diff --git a/homeassistant/components/logger.py b/homeassistant/components/logger.py index c2309401977..6e8995a0444 100644 --- a/homeassistant/components/logger.py +++ b/homeassistant/components/logger.py @@ -4,7 +4,6 @@ Component that will help set the level of logging for components. For more details about this component, please refer to the documentation at https://home-assistant.io/components/logger/ """ -import asyncio import logging from collections import OrderedDict @@ -73,8 +72,7 @@ class HomeAssistantLogFilter(logging.Filter): return record.levelno >= default -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the logger component.""" logfilter = {} @@ -116,8 +114,7 @@ def async_setup(hass, config): if LOGGER_LOGS in config.get(DOMAIN): set_log_levels(config.get(DOMAIN)[LOGGER_LOGS]) - @asyncio.coroutine - def async_service_handler(service): + async def async_service_handler(service): """Handle logger services.""" set_log_levels(service.data) diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index 63f0315f35c..7b1b7417cfd 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylutron-caseta==0.3.0'] +REQUIREMENTS = ['pylutron-caseta==0.5.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/map.py b/homeassistant/components/map.py index b8293f64fc0..30cb00af69e 100644 --- a/homeassistant/components/map.py +++ b/homeassistant/components/map.py @@ -4,14 +4,11 @@ Provides a map panel for showing device locations. For more details about this component, please refer to the documentation at https://home-assistant.io/components/map/ """ -import asyncio - DOMAIN = 'map' -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Register the built-in map panel.""" - yield from hass.components.frontend.async_register_built_in_panel( + await hass.components.frontend.async_register_built_in_panel( 'map', 'map', 'mdi:account-location') return True diff --git a/homeassistant/components/matrix.py b/homeassistant/components/matrix.py new file mode 100644 index 00000000000..b2805c994e8 --- /dev/null +++ b/homeassistant/components/matrix.py @@ -0,0 +1,344 @@ +""" +The matrix bot component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/matrix/ +""" +import logging +import os +from functools import partial + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import (ATTR_TARGET, ATTR_MESSAGE) +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, + CONF_VERIFY_SSL, CONF_NAME, + EVENT_HOMEASSISTANT_STOP, + EVENT_HOMEASSISTANT_START) +from homeassistant.util.json import load_json, save_json +from homeassistant.exceptions import HomeAssistantError + +REQUIREMENTS = ['matrix-client==0.2.0'] + +_LOGGER = logging.getLogger(__name__) + +SESSION_FILE = '.matrix.conf' + +CONF_HOMESERVER = 'homeserver' +CONF_ROOMS = 'rooms' +CONF_COMMANDS = 'commands' +CONF_WORD = 'word' +CONF_EXPRESSION = 'expression' + +EVENT_MATRIX_COMMAND = 'matrix_command' + +DOMAIN = 'matrix' + +COMMAND_SCHEMA = vol.All( + # Basic Schema + vol.Schema({ + vol.Exclusive(CONF_WORD, 'trigger'): cv.string, + vol.Exclusive(CONF_EXPRESSION, 'trigger'): cv.is_regex, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ROOMS, default=[]): vol.All(cv.ensure_list, + [cv.string]), + }), + # Make sure it's either a word or an expression command + cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION) +) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOMESERVER): cv.url, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Required(CONF_USERNAME): cv.matches_regex("@[^:]*:.*"), + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_ROOMS, default=[]): vol.All(cv.ensure_list, + [cv.string]), + vol.Optional(CONF_COMMANDS, default=[]): [COMMAND_SCHEMA] + }) +}, extra=vol.ALLOW_EXTRA) + +SERVICE_SEND_MESSAGE = 'send_message' + +SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema({ + vol.Required(ATTR_MESSAGE): cv.string, + vol.Required(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), +}) + + +def setup(hass, config): + """Set up the Matrix bot component.""" + from matrix_client.client import MatrixRequestError + + config = config[DOMAIN] + + try: + bot = MatrixBot( + hass, + os.path.join(hass.config.path(), SESSION_FILE), + config[CONF_HOMESERVER], + config[CONF_VERIFY_SSL], + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_ROOMS], + config[CONF_COMMANDS]) + hass.data[DOMAIN] = bot + except MatrixRequestError as exception: + _LOGGER.error("Matrix failed to log in: %s", str(exception)) + return False + + hass.services.register( + DOMAIN, SERVICE_SEND_MESSAGE, bot.handle_send_message, + schema=SERVICE_SCHEMA_SEND_MESSAGE) + + return True + + +class MatrixBot(object): + """The Matrix Bot.""" + + def __init__(self, hass, config_file, homeserver, verify_ssl, + username, password, listening_rooms, commands): + """Set up the client.""" + self.hass = hass + + self._session_filepath = config_file + self._auth_tokens = self._get_auth_tokens() + + self._homeserver = homeserver + self._verify_tls = verify_ssl + self._mx_id = username + self._password = password + + self._listening_rooms = listening_rooms + + # We have to fetch the aliases for every room to make sure we don't + # join it twice by accident. However, fetching aliases is costly, + # so we only do it once per room. + self._aliases_fetched_for = set() + + # word commands are stored dict-of-dict: First dict indexes by room ID + # / alias, second dict indexes by the word + self._word_commands = {} + + # regular expression commands are stored as a list of commands per + # room, i.e., a dict-of-list + self._expression_commands = {} + + for command in commands: + if not command.get(CONF_ROOMS): + command[CONF_ROOMS] = listening_rooms + + if command.get(CONF_WORD): + for room_id in command[CONF_ROOMS]: + if room_id not in self._word_commands: + self._word_commands[room_id] = {} + self._word_commands[room_id][command[CONF_WORD]] = command + else: + for room_id in command[CONF_ROOMS]: + if room_id not in self._expression_commands: + self._expression_commands[room_id] = [] + self._expression_commands[room_id].append(command) + + # Log in. This raises a MatrixRequestError if login is unsuccessful + self._client = self._login() + + def handle_matrix_exception(exception): + """Handle exceptions raised inside the Matrix SDK.""" + _LOGGER.error("Matrix exception:\n %s", str(exception)) + + self._client.start_listener_thread( + exception_handler=handle_matrix_exception) + + def stop_client(_): + """Run once when Home Assistant stops.""" + self._client.stop_listener_thread() + + self.hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_client) + + # Joining rooms potentially does a lot of I/O, so we defer it + def handle_startup(_): + """Run once when Home Assistant finished startup.""" + self._join_rooms() + + self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START, handle_startup) + + def _handle_room_message(self, room_id, room, event): + """Handle a message sent to a room.""" + if event['content']['msgtype'] != 'm.text': + return + + if event['sender'] == self._mx_id: + return + + _LOGGER.debug("Handling message: %s", event['content']['body']) + + if event['content']['body'][0] == "!": + # Could trigger a single-word command. + pieces = event['content']['body'].split(' ') + cmd = pieces[0][1:] + + command = self._word_commands.get(room_id, {}).get(cmd) + if command: + event_data = { + 'command': command[CONF_NAME], + 'sender': event['sender'], + 'room': room_id, + 'args': pieces[1:] + } + self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data) + + # After single-word commands, check all regex commands in the room + for command in self._expression_commands.get(room_id, []): + match = command[CONF_EXPRESSION].match(event['content']['body']) + if not match: + continue + event_data = { + 'command': command[CONF_NAME], + 'sender': event['sender'], + 'room': room_id, + 'args': match.groupdict() + } + self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data) + + def _join_or_get_room(self, room_id_or_alias): + """Join a room or get it, if we are already in the room. + + We can't just always call join_room(), since that seems to crash + the client if we're already in the room. + """ + rooms = self._client.get_rooms() + if room_id_or_alias in rooms: + _LOGGER.debug("Already in room %s", room_id_or_alias) + return rooms[room_id_or_alias] + + for room in rooms.values(): + if room.room_id not in self._aliases_fetched_for: + room.update_aliases() + self._aliases_fetched_for.add(room.room_id) + + if room_id_or_alias in room.aliases: + _LOGGER.debug("Already in room %s (known as %s)", + room.room_id, room_id_or_alias) + return room + + room = self._client.join_room(room_id_or_alias) + _LOGGER.info("Joined room %s (known as %s)", room.room_id, + room_id_or_alias) + return room + + def _join_rooms(self): + """Join the rooms that we listen for commands in.""" + from matrix_client.client import MatrixRequestError + + for room_id in self._listening_rooms: + try: + room = self._join_or_get_room(room_id) + room.add_listener(partial(self._handle_room_message, room_id), + "m.room.message") + + except MatrixRequestError as ex: + _LOGGER.error("Could not join room %s: %s", room_id, ex) + + def _get_auth_tokens(self): + """ + Read sorted authentication tokens from disk. + + Returns the auth_tokens dictionary. + """ + try: + auth_tokens = load_json(self._session_filepath) + + return auth_tokens + except HomeAssistantError as ex: + _LOGGER.warning( + "Loading authentication tokens from file '%s' failed: %s", + self._session_filepath, str(ex)) + return {} + + def _store_auth_token(self, token): + """Store authentication token to session and persistent storage.""" + self._auth_tokens[self._mx_id] = token + + save_json(self._session_filepath, self._auth_tokens) + + def _login(self): + """Login to the matrix homeserver and return the client instance.""" + from matrix_client.client import MatrixRequestError + + # Attempt to generate a valid client using either of the two possible + # login methods: + client = None + + # If we have an authentication token + if self._mx_id in self._auth_tokens: + try: + client = self._login_by_token() + _LOGGER.debug("Logged in using stored token.") + + except MatrixRequestError as ex: + _LOGGER.warning( + "Login by token failed, falling back to password. " + "login_by_token raised: (%d) %s", + ex.code, ex.content) + + # If we still don't have a client try password. + if not client: + try: + client = self._login_by_password() + _LOGGER.debug("Logged in using password.") + + except MatrixRequestError as ex: + _LOGGER.error( + "Login failed, both token and username/password invalid " + "login_by_password raised: (%d) %s", + ex.code, ex.content) + + # re-raise the error so _setup can catch it. + raise + + return client + + def _login_by_token(self): + """Login using authentication token and return the client.""" + from matrix_client.client import MatrixClient + + return MatrixClient( + base_url=self._homeserver, + token=self._auth_tokens[self._mx_id], + user_id=self._mx_id, + valid_cert_check=self._verify_tls) + + def _login_by_password(self): + """Login using password authentication and return the client.""" + from matrix_client.client import MatrixClient + + _client = MatrixClient( + base_url=self._homeserver, + valid_cert_check=self._verify_tls) + + _client.login_with_password(self._mx_id, self._password) + + self._store_auth_token(_client.token) + + return _client + + def _send_message(self, message, target_rooms): + """Send the message to the matrix server.""" + from matrix_client.client import MatrixRequestError + + for target_room in target_rooms: + try: + room = self._join_or_get_room(target_room) + _LOGGER.debug(room.send_text(message)) + except MatrixRequestError as ex: + _LOGGER.error( + "Unable to deliver message to room '%s': (%d): %s", + target_room, ex.code, ex.content) + + def handle_send_message(self, service): + """Handle the send_message service.""" + self._send_message(service.data[ATTR_MESSAGE], + service.data[ATTR_TARGET]) diff --git a/homeassistant/components/maxcube.py b/homeassistant/components/maxcube.py index a0a8db6ba4d..bca7a1b4ab7 100644 --- a/homeassistant/components/maxcube.py +++ b/homeassistant/components/maxcube.py @@ -13,7 +13,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL REQUIREMENTS = ['maxcube-api==0.1.0'] @@ -22,12 +22,23 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_PORT = 62910 DOMAIN = 'maxcube' -MAXCUBE_HANDLE = 'maxcube' +DATA_KEY = 'maxcube' + +NOTIFICATION_ID = 'maxcube_notification' +NOTIFICATION_TITLE = 'Max!Cube gateway setup' + +CONF_GATEWAYS = 'gateways' + +CONFIG_GATEWAY = vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SCAN_INTERVAL, default=300): cv.time_period, +}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_GATEWAYS, default={}): + vol.All(cv.ensure_list, [CONFIG_GATEWAY]) }), }, extra=vol.ALLOW_EXTRA) @@ -36,19 +47,32 @@ def setup(hass, config): """Establish connection to MAX! Cube.""" from maxcube.connection import MaxCubeConnection from maxcube.cube import MaxCube + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} - host = config.get(DOMAIN).get(CONF_HOST) - port = config.get(DOMAIN).get(CONF_PORT) + connection_failed = 0 + gateways = config[DOMAIN][CONF_GATEWAYS] + for gateway in gateways: + host = gateway[CONF_HOST] + port = gateway[CONF_PORT] + scan_interval = gateway[CONF_SCAN_INTERVAL].total_seconds() - try: - cube = MaxCube(MaxCubeConnection(host, port)) - except timeout: - _LOGGER.error("Connection to Max!Cube could not be established") - cube = None + try: + cube = MaxCube(MaxCubeConnection(host, port)) + hass.data[DATA_KEY][host] = MaxCubeHandle(cube, scan_interval) + except timeout as ex: + _LOGGER.error("Unable to connect to Max!Cube gateway: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart Home Assistant after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + connection_failed += 1 + + if connection_failed >= len(gateways): return False - hass.data[MAXCUBE_HANDLE] = MaxCubeHandle(cube) - load_platform(hass, 'climate', DOMAIN) load_platform(hass, 'binary_sensor', DOMAIN) @@ -58,9 +82,10 @@ def setup(hass, config): class MaxCubeHandle(object): """Keep the cube instance in one place and centralize the update.""" - def __init__(self, cube): + def __init__(self, cube, scan_interval): """Initialize the Cube Handle.""" self.cube = cube + self.scan_interval = scan_interval self.mutex = Lock() self._updatets = time.time() @@ -68,8 +93,8 @@ class MaxCubeHandle(object): """Pull the latest data from the MAX! Cube.""" # Acquire mutex to prevent simultaneous update from multiple threads with self.mutex: - # Only update every 60s - if (time.time() - self._updatets) >= 60: + # Only update every update_interval + if (time.time() - self._updatets) >= self.scan_interval: _LOGGER.debug("Updating") try: diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index e10a713995b..75b90b084fc 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.03.10'] +REQUIREMENTS = ['youtube_dl==2018.06.02'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 37536bf5586..20a1a473ba8 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/media_player/ """ import asyncio +import base64 from datetime import timedelta import functools as ft import collections @@ -17,6 +18,7 @@ from aiohttp.hdrs import CONTENT_TYPE, CACHE_CONTROL import async_timeout import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.const import ( STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, ATTR_ENTITY_ID, @@ -31,6 +33,7 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import bind_hass +from homeassistant.components import websocket_api _LOGGER = logging.getLogger(__name__) _RND = SystemRandom() @@ -83,7 +86,8 @@ ATTR_MEDIA_SHUFFLE = 'shuffle' MEDIA_TYPE_MUSIC = 'music' MEDIA_TYPE_TVSHOW = 'tvshow' -MEDIA_TYPE_VIDEO = 'movie' +MEDIA_TYPE_MOVIE = 'movie' +MEDIA_TYPE_VIDEO = 'video' MEDIA_TYPE_EPISODE = 'episode' MEDIA_TYPE_CHANNEL = 'channel' MEDIA_TYPE_PLAYLIST = 'playlist' @@ -360,18 +364,27 @@ def set_shuffle(hass, shuffle, entity_id=None): hass.services.call(DOMAIN, SERVICE_SHUFFLE_SET, data) -@asyncio.coroutine -def async_setup(hass, config): +WS_TYPE_MEDIA_PLAYER_THUMBNAIL = 'media_player_thumbnail' +SCHEMA_WEBSOCKET_GET_THUMBNAIL = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + 'type': WS_TYPE_MEDIA_PLAYER_THUMBNAIL, + 'entity_id': cv.entity_id + }) + + +async def async_setup(hass, config): """Track states and offer events for media_players.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) + hass.components.websocket_api.async_register_command( + WS_TYPE_MEDIA_PLAYER_THUMBNAIL, websocket_handle_thumbnail, + SCHEMA_WEBSOCKET_GET_THUMBNAIL) hass.http.register_view(MediaPlayerImageView(component)) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_service_handler(service): + async def async_service_handler(service): """Map services to methods on MediaPlayerDevice.""" method = SERVICE_TO_METHOD.get(service.service) if not method: @@ -399,13 +412,13 @@ def async_setup(hass, config): update_tasks = [] for player in target_players: - yield from getattr(player, method['method'])(**params) + await getattr(player, method['method'])(**params) if not player.should_poll: continue update_tasks.append(player.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) for service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service].get( @@ -489,14 +502,13 @@ class MediaPlayerDevice(Entity): return None - @asyncio.coroutine - def async_get_media_image(self): + async def async_get_media_image(self): """Fetch media image of current playing image.""" url = self.media_image_url if url is None: return None, None - return (yield from _async_fetch_image(self.hass, url)) + return await _async_fetch_image(self.hass, url) @property def media_title(self): @@ -807,34 +819,31 @@ class MediaPlayerDevice(Entity): return self.async_turn_on() return self.async_turn_off() - @asyncio.coroutine - def async_volume_up(self): + async def async_volume_up(self): """Turn volume up for media player. This method is a coroutine. """ if hasattr(self, 'volume_up'): # pylint: disable=no-member - yield from self.hass.async_add_job(self.volume_up) + await self.hass.async_add_job(self.volume_up) return if self.volume_level < 1: - yield from self.async_set_volume_level( - min(1, self.volume_level + .1)) + await self.async_set_volume_level(min(1, self.volume_level + .1)) - @asyncio.coroutine - def async_volume_down(self): + async def async_volume_down(self): """Turn volume down for media player. This method is a coroutine. """ if hasattr(self, 'volume_down'): # pylint: disable=no-member - yield from self.hass.async_add_job(self.volume_down) + await self.hass.async_add_job(self.volume_down) return if self.volume_level > 0: - yield from self.async_set_volume_level( + await self.async_set_volume_level( max(0, self.volume_level - .1)) def async_media_play_pause(self): @@ -878,8 +887,7 @@ class MediaPlayerDevice(Entity): return state_attr -@asyncio.coroutine -def _async_fetch_image(hass, url): +async def _async_fetch_image(hass, url): """Fetch image. Images are cached in memory (the images are typically 10-100kB in size). @@ -890,7 +898,7 @@ def _async_fetch_image(hass, url): if url not in cache_images: cache_images[url] = {CACHE_LOCK: asyncio.Lock(loop=hass.loop)} - with (yield from cache_images[url][CACHE_LOCK]): + async with cache_images[url][CACHE_LOCK]: if CACHE_CONTENT in cache_images[url]: return cache_images[url][CACHE_CONTENT] @@ -898,10 +906,10 @@ def _async_fetch_image(hass, url): websession = async_get_clientsession(hass) try: with async_timeout.timeout(10, loop=hass.loop): - response = yield from websession.get(url) + response = await websession.get(url) if response.status == 200: - content = yield from response.read() + content = await response.read() content_type = response.headers.get(CONTENT_TYPE) if content_type: content_type = content_type.split(';')[0] @@ -927,8 +935,7 @@ class MediaPlayerImageView(HomeAssistantView): """Initialize a media player view.""" self.component = component - @asyncio.coroutine - def get(self, request, entity_id): + async def get(self, request, entity_id): """Start a get request.""" player = self.component.get_entity(entity_id) if player is None: @@ -941,7 +948,7 @@ class MediaPlayerImageView(HomeAssistantView): if not authenticated: return web.Response(status=401) - data, content_type = yield from player.async_get_media_image() + data, content_type = await player.async_get_media_image() if data is None: return web.Response(status=500) @@ -949,3 +956,36 @@ class MediaPlayerImageView(HomeAssistantView): headers = {CACHE_CONTROL: 'max-age=3600'} return web.Response( body=data, content_type=content_type, headers=headers) + + +@callback +def websocket_handle_thumbnail(hass, connection, msg): + """Handle get media player cover command. + + Async friendly. + """ + component = hass.data[DOMAIN] + player = component.get_entity(msg['entity_id']) + + if player is None: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'entity_not_found', 'Entity not found')) + return + + async def send_image(): + """Send image.""" + data, content_type = await player.async_get_media_image() + + if data is None: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'thumbnail_fetch_failed', + 'Failed to fetch thumbnail')) + return + + connection.send_message_outside(websocket_api.result_message( + msg['id'], { + 'content_type': content_type, + 'content': base64.b64encode(data).decode('utf-8') + })) + + hass.async_add_job(send_image()) diff --git a/homeassistant/components/media_player/blackbird.py b/homeassistant/components/media_player/blackbird.py new file mode 100644 index 00000000000..1c976f5eecd --- /dev/null +++ b/homeassistant/components/media_player/blackbird.py @@ -0,0 +1,209 @@ +""" +Support for interfacing with Monoprice Blackbird 4k 8x8 HDBaseT Matrix. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.blackbird +""" +import logging +import socket + +import voluptuous as vol + +from homeassistant.components.media_player import ( + DOMAIN, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA, SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_NAME, CONF_HOST, CONF_PORT, STATE_OFF, STATE_ON) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyblackbird==0.5'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_BLACKBIRD = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ + SUPPORT_SELECT_SOURCE + +ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + +SOURCE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + +CONF_ZONES = 'zones' +CONF_SOURCES = 'sources' +CONF_TYPE = 'type' + +DATA_BLACKBIRD = 'blackbird' + +SERVICE_SETALLZONES = 'blackbird_set_all_zones' +ATTR_SOURCE = 'source' + +BLACKBIRD_SETALLZONES_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ + vol.Required(ATTR_SOURCE): cv.string +}) + + +# Valid zone ids: 1-8 +ZONE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=8)) + +# Valid source ids: 1-8 +SOURCE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=8)) + +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_PORT, CONF_HOST), + PLATFORM_SCHEMA.extend({ + vol.Exclusive(CONF_PORT, CONF_TYPE): cv.string, + vol.Exclusive(CONF_HOST, CONF_TYPE): cv.string, + vol.Required(CONF_ZONES): vol.Schema({ZONE_IDS: ZONE_SCHEMA}), + vol.Required(CONF_SOURCES): vol.Schema({SOURCE_IDS: SOURCE_SCHEMA}), + })) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Monoprice Blackbird 4k 8x8 HDBaseT Matrix platform.""" + if DATA_BLACKBIRD not in hass.data: + hass.data[DATA_BLACKBIRD] = {} + + port = config.get(CONF_PORT) + host = config.get(CONF_HOST) + + from pyblackbird import get_blackbird + from serial import SerialException + + connection = None + if port is not None: + try: + blackbird = get_blackbird(port) + connection = port + except SerialException: + _LOGGER.error("Error connecting to the Blackbird controller") + return + + if host is not None: + try: + blackbird = get_blackbird(host, False) + connection = host + except socket.timeout: + _LOGGER.error("Error connecting to the Blackbird controller") + return + + sources = {source_id: extra[CONF_NAME] for source_id, extra + in config[CONF_SOURCES].items()} + + devices = [] + for zone_id, extra in config[CONF_ZONES].items(): + _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME]) + unique_id = "{}-{}".format(connection, zone_id) + device = BlackbirdZone(blackbird, sources, zone_id, extra[CONF_NAME]) + hass.data[DATA_BLACKBIRD][unique_id] = device + devices.append(device) + + add_devices(devices, True) + + def service_handle(service): + """Handle for services.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + source = service.data.get(ATTR_SOURCE) + if entity_ids: + devices = [device for device in hass.data[DATA_BLACKBIRD].values() + if device.entity_id in entity_ids] + + else: + devices = hass.data[DATA_BLACKBIRD].values() + + for device in devices: + if service.service == SERVICE_SETALLZONES: + device.set_all_zones(source) + + hass.services.register(DOMAIN, SERVICE_SETALLZONES, service_handle, + schema=BLACKBIRD_SETALLZONES_SCHEMA) + + +class BlackbirdZone(MediaPlayerDevice): + """Representation of a Blackbird matrix zone.""" + + def __init__(self, blackbird, sources, zone_id, zone_name): + """Initialize new zone.""" + self._blackbird = blackbird + # dict source_id -> source name + self._source_id_name = sources + # dict source name -> source_id + self._source_name_id = {v: k for k, v in sources.items()} + # ordered list of all source names + self._source_names = sorted(self._source_name_id.keys(), + key=lambda v: self._source_name_id[v]) + self._zone_id = zone_id + self._name = zone_name + self._state = None + self._source = None + + def update(self): + """Retrieve latest state.""" + state = self._blackbird.zone_status(self._zone_id) + if not state: + return + self._state = STATE_ON if state.power else STATE_OFF + idx = state.av + if idx in self._source_id_name: + self._source = self._source_id_name[idx] + else: + self._source = None + + @property + def name(self): + """Return the name of the zone.""" + return self._name + + @property + def state(self): + """Return the state of the zone.""" + return self._state + + @property + def supported_features(self): + """Return flag of media commands that are supported.""" + return SUPPORT_BLACKBIRD + + @property + def media_title(self): + """Return the current source as media title.""" + return self._source + + @property + def source(self): + """Return the current input source of the device.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_names + + def set_all_zones(self, source): + """Set all zones to one source.""" + if source not in self._source_name_id: + return + idx = self._source_name_id[source] + _LOGGER.debug("Setting all zones source to %s", idx) + self._blackbird.set_all_zone_source(idx) + + def select_source(self, source): + """Set input source.""" + if source not in self._source_name_id: + return + idx = self._source_name_id[source] + _LOGGER.debug("Setting zone %d source to %s", self._zone_id, idx) + self._blackbird.set_zone_source(self._zone_id, idx) + + def turn_on(self): + """Turn the media player on.""" + _LOGGER.debug("Turning zone %d on", self._zone_id) + self._blackbird.set_zone_power(self._zone_id, True) + + def turn_off(self): + """Turn the media player off.""" + _LOGGER.debug("Turning zone %d off", self._zone_id) + self._blackbird.set_zone_power(self._zone_id, False) diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index a07e577c969..283c4af032e 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -37,30 +37,30 @@ REQUIREMENTS = ['xmltodict==0.11.0'] _LOGGER = logging.getLogger(__name__) -STATE_GROUPED = 'grouped' - ATTR_MASTER = 'master' -SERVICE_JOIN = 'bluesound_join' -SERVICE_UNJOIN = 'bluesound_unjoin' -SERVICE_SET_TIMER = 'bluesound_set_sleep_timer' -SERVICE_CLEAR_TIMER = 'bluesound_clear_sleep_timer' - DATA_BLUESOUND = 'bluesound' DEFAULT_PORT = 11000 -SYNC_STATUS_INTERVAL = timedelta(minutes=5) -UPDATE_CAPTURE_INTERVAL = timedelta(minutes=30) -UPDATE_SERVICES_INTERVAL = timedelta(minutes=30) -UPDATE_PRESETS_INTERVAL = timedelta(minutes=30) NODE_OFFLINE_CHECK_TIMEOUT = 180 NODE_RETRY_INITIATION = timedelta(minutes=3) +SERVICE_CLEAR_TIMER = 'bluesound_clear_sleep_timer' +SERVICE_JOIN = 'bluesound_join' +SERVICE_SET_TIMER = 'bluesound_set_sleep_timer' +SERVICE_UNJOIN = 'bluesound_unjoin' +STATE_GROUPED = 'grouped' +SYNC_STATUS_INTERVAL = timedelta(minutes=5) + +UPDATE_CAPTURE_INTERVAL = timedelta(minutes=30) +UPDATE_PRESETS_INTERVAL = timedelta(minutes=30) +UPDATE_SERVICES_INTERVAL = timedelta(minutes=30) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOSTS): vol.All(cv.ensure_list, [{ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }]) }) @@ -131,8 +131,8 @@ def _add_player(hass, async_add_devices, host, port=None, name=None): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_player) -@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 Bluesound platforms.""" if DATA_BLUESOUND not in hass.data: hass.data[DATA_BLUESOUND] = [] @@ -149,8 +149,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hass, async_add_devices, host.get(CONF_HOST), host.get(CONF_PORT), host.get(CONF_NAME)) - @asyncio.coroutine - def async_service_handler(service): + async def async_service_handler(service): """Map services to method of Bluesound devices.""" method = SERVICE_TO_METHOD.get(service.service) if not method: @@ -166,7 +165,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): target_players = hass.data[DATA_BLUESOUND] for player in target_players: - yield from getattr(player, method['method'])(**params) + await getattr(player, method['method'])(**params) for service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service]['schema'] @@ -203,6 +202,9 @@ class BluesoundPlayer(MediaPlayerDevice): if self.port is None: self.port = DEFAULT_PORT + class _TimeoutException(Exception): + pass + @staticmethod def _try_get_index(string, search_string): """Get the index.""" @@ -211,13 +213,12 @@ class BluesoundPlayer(MediaPlayerDevice): except ValueError: return -1 - @asyncio.coroutine - def force_update_sync_status( + async def force_update_sync_status( self, on_updated_cb=None, raise_timeout=False): """Update the internal status.""" resp = None try: - resp = yield from self.send_bluesound_command( + resp = await self.send_bluesound_command( 'SyncStatus', raise_timeout, raise_timeout) except Exception: raise @@ -254,16 +255,16 @@ class BluesoundPlayer(MediaPlayerDevice): on_updated_cb() return True - @asyncio.coroutine - def _start_poll_command(self): + async def _start_poll_command(self): """Loop which polls the status of the player.""" try: while True: - yield from self.async_update_status() + await self.async_update_status() - except (asyncio.TimeoutError, ClientError): + except (asyncio.TimeoutError, ClientError, + BluesoundPlayer._TimeoutException): _LOGGER.info("Node %s is offline, retrying later", self._name) - yield from asyncio.sleep( + await asyncio.sleep( NODE_OFFLINE_CHECK_TIMEOUT, loop=self._hass.loop) self.start_polling() @@ -282,39 +283,36 @@ class BluesoundPlayer(MediaPlayerDevice): """Stop the polling task.""" self._polling_task.cancel() - @asyncio.coroutine - def async_init(self): + async def async_init(self, triggered=None): """Initialize the player async.""" try: if self._retry_remove is not None: self._retry_remove() self._retry_remove = None - yield from self.force_update_sync_status( + await self.force_update_sync_status( self._init_callback, True) except (asyncio.TimeoutError, ClientError): _LOGGER.info("Node %s is offline, retrying later", self.host) self._retry_remove = async_track_time_interval( self._hass, self.async_init, NODE_RETRY_INITIATION) except Exception: - _LOGGER.exception("Unexpected when initiating error in %s", - self.host) + _LOGGER.exception( + "Unexpected when initiating error in %s", self.host) raise - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update internal status of the entity.""" if not self._is_online: return - yield from self.async_update_sync_status() - yield from self.async_update_presets() - yield from self.async_update_captures() - yield from self.async_update_services() + await self.async_update_sync_status() + await self.async_update_presets() + await self.async_update_captures() + await self.async_update_services() - @asyncio.coroutine - def send_bluesound_command(self, method, raise_timeout=False, - allow_offline=False): + async def send_bluesound_command( + self, method, raise_timeout=False, allow_offline=False): """Send command to the player.""" import xmltodict @@ -327,17 +325,21 @@ class BluesoundPlayer(MediaPlayerDevice): _LOGGER.debug("Calling URL: %s", url) response = None + try: websession = async_get_clientsession(self._hass) with async_timeout.timeout(10, loop=self._hass.loop): - response = yield from websession.get(url) + response = await websession.get(url) if response.status == 200: - result = yield from response.text() + result = await response.text() if len(result) < 1: data = None else: data = xmltodict.parse(result) + elif response.status == 595: + _LOGGER.info("Status 595 returned, treating as timeout") + raise BluesoundPlayer._TimeoutException() else: _LOGGER.error("Error %s on %s", response.status, url) return None @@ -352,8 +354,7 @@ class BluesoundPlayer(MediaPlayerDevice): return data - @asyncio.coroutine - def async_update_status(self): + async def async_update_status(self): """Use the poll session to always get the status of the player.""" import xmltodict response = None @@ -372,28 +373,24 @@ class BluesoundPlayer(MediaPlayerDevice): try: with async_timeout.timeout(125, loop=self._hass.loop): - response = yield from self._polling_session.get( - url, - headers={CONNECTION: KEEP_ALIVE}) + response = await self._polling_session.get( + url, headers={CONNECTION: KEEP_ALIVE}) - if response.status != 200: - _LOGGER.error("Error %s on %s. Trying one more time.", - response.status, url) - else: - result = yield from response.text() + if response.status == 200: + result = await response.text() self._is_online = True self._last_status_update = dt_util.utcnow() self._status = xmltodict.parse(result)['status'].copy() group_name = self._status.get('groupName', None) if group_name != self._group_name: - _LOGGER.debug('Group name change detected on device: %s', - self.host) + _LOGGER.debug( + "Group name change detected on device: %s", self.host) self._group_name = group_name # the sleep is needed to make sure that the # devices is synced - yield from asyncio.sleep(1, loop=self._hass.loop) - yield from self.async_trigger_sync_on_all() + await asyncio.sleep(1, loop=self._hass.loop) + await self.async_trigger_sync_on_all() elif self.is_grouped: # when player is grouped we need to fetch volume from # sync_status. We will force an update if the player is @@ -402,30 +399,35 @@ class BluesoundPlayer(MediaPlayerDevice): # the device is playing. This would solve alot of # problems. This change will be done when the # communication is moved to a separate library - yield from self.force_update_sync_status() + await self.force_update_sync_status() self.async_schedule_update_ha_state() + elif response.status == 595: + _LOGGER.info("Status 595 returned, treating as timeout") + raise BluesoundPlayer._TimeoutException() + else: + _LOGGER.error("Error %s on %s. Trying one more time", + response.status, url) except (asyncio.TimeoutError, ClientError): self._is_online = False self._last_status_update = None self._status = None self.async_schedule_update_ha_state() - _LOGGER.info("Client connection error, marking %s as offline", - self._name) + _LOGGER.info( + "Client connection error, marking %s as offline", self._name) raise - @asyncio.coroutine - def async_trigger_sync_on_all(self): + async def async_trigger_sync_on_all(self): """Trigger sync status update on all devices.""" _LOGGER.debug("Trigger sync status on all devices") for player in self._hass.data[DATA_BLUESOUND]: - yield from player.force_update_sync_status() + await player.force_update_sync_status() @Throttle(SYNC_STATUS_INTERVAL) - async def async_update_sync_status(self, on_updated_cb=None, - raise_timeout=False): + async def async_update_sync_status( + self, on_updated_cb=None, raise_timeout=False): """Update sync status.""" await self.force_update_sync_status( on_updated_cb, raise_timeout=False) @@ -473,7 +475,7 @@ class BluesoundPlayer(MediaPlayerDevice): 'image': item.get('@image', ''), 'is_raw_url': True, 'url2': item.get('@url', ''), - 'url': 'Preset?id=' + item.get('@id', '') + 'url': 'Preset?id={}'.format(item.get('@id', '')) }) if 'presets' in resp and 'preset' in resp['presets']: @@ -511,11 +513,6 @@ class BluesoundPlayer(MediaPlayerDevice): return self._services_items - @property - def should_poll(self): - """No need to poll information.""" - return True - @property def media_content_type(self): """Content type of current playing media.""" @@ -788,8 +785,7 @@ class BluesoundPlayer(MediaPlayerDevice): """Return true if shuffle is active.""" return True if self._status.get('shuffle', '0') == '1' else False - @asyncio.coroutine - def async_join(self, master): + async def async_join(self, master): """Join the player to a group.""" master_device = [device for device in self.hass.data[DATA_BLUESOUND] if device.entity_id == master] @@ -798,59 +794,53 @@ class BluesoundPlayer(MediaPlayerDevice): _LOGGER.debug("Trying to join player: %s to master: %s", self.host, master_device[0].host) - yield from master_device[0].async_add_slave(self) + await master_device[0].async_add_slave(self) else: _LOGGER.error("Master not found %s", master_device) - @asyncio.coroutine - def async_unjoin(self): + async def async_unjoin(self): """Unjoin the player from a group.""" if self._master is None: return _LOGGER.debug("Trying to unjoin player: %s", self.host) - yield from self._master.async_remove_slave(self) + await self._master.async_remove_slave(self) - @asyncio.coroutine - def async_add_slave(self, slave_device): + async def async_add_slave(self, slave_device): """Add slave to master.""" - return self.send_bluesound_command('/AddSlave?slave={}&port={}' - .format(slave_device.host, - slave_device.port)) + return await self.send_bluesound_command( + '/AddSlave?slave={}&port={}'.format( + slave_device.host, slave_device.port)) - @asyncio.coroutine - def async_remove_slave(self, slave_device): + async def async_remove_slave(self, slave_device): """Remove slave to master.""" - return self.send_bluesound_command('/RemoveSlave?slave={}&port={}' - .format(slave_device.host, - slave_device.port)) + return await self.send_bluesound_command( + '/RemoveSlave?slave={}&port={}'.format( + slave_device.host, slave_device.port)) - @asyncio.coroutine - def async_increase_timer(self): + async def async_increase_timer(self): """Increase sleep time on player.""" - sleep_time = yield from self.send_bluesound_command('/Sleep') + sleep_time = await self.send_bluesound_command('/Sleep') if sleep_time is None: - _LOGGER.error('Error while increasing sleep time on player: %s', - self.host) + _LOGGER.error( + "Error while increasing sleep time on player: %s", self.host) return 0 return int(sleep_time.get('sleep', '0')) - @asyncio.coroutine - def async_clear_timer(self): + async def async_clear_timer(self): """Clear sleep timer on player.""" sleep = 1 while sleep > 0: - sleep = yield from self.async_increase_timer() + sleep = await self.async_increase_timer() - @asyncio.coroutine - def async_set_shuffle(self, shuffle): + async def async_set_shuffle(self, shuffle): """Enable or disable shuffle mode.""" - return self.send_bluesound_command('/Shuffle?state={}' - .format('1' if shuffle else '0')) + value = '1' if shuffle else '0' + return await self.send_bluesound_command( + '/Shuffle?state={}'.format(value)) - @asyncio.coroutine - def async_select_source(self, source): + async def async_select_source(self, source): """Select input source.""" if self.is_grouped and not self.is_master: return @@ -872,18 +862,16 @@ class BluesoundPlayer(MediaPlayerDevice): if 'is_raw_url' in selected_source and selected_source['is_raw_url']: url = selected_source['url'] - return self.send_bluesound_command(url) + return await self.send_bluesound_command(url) - @asyncio.coroutine - def async_clear_playlist(self): + async def async_clear_playlist(self): """Clear players playlist.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Clear') + return await self.send_bluesound_command('Clear') - @asyncio.coroutine - def async_media_next_track(self): + async def async_media_next_track(self): """Send media_next command to media player.""" if self.is_grouped and not self.is_master: return @@ -895,10 +883,9 @@ class BluesoundPlayer(MediaPlayerDevice): action['@name'] == 'skip'): cmd = action['@url'] - return self.send_bluesound_command(cmd) + return await self.send_bluesound_command(cmd) - @asyncio.coroutine - def async_media_previous_track(self): + async def async_media_previous_track(self): """Send media_previous command to media player.""" if self.is_grouped and not self.is_master: return @@ -910,42 +897,38 @@ class BluesoundPlayer(MediaPlayerDevice): action['@name'] == 'back'): cmd = action['@url'] - return self.send_bluesound_command(cmd) + return await self.send_bluesound_command(cmd) - @asyncio.coroutine - def async_media_play(self): + async def async_media_play(self): """Send media_play command to media player.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Play') + return await self.send_bluesound_command('Play') - @asyncio.coroutine - def async_media_pause(self): + async def async_media_pause(self): """Send media_pause command to media player.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Pause') + return await self.send_bluesound_command('Pause') - @asyncio.coroutine - def async_media_stop(self): + async def async_media_stop(self): """Send stop command.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Pause') + return await self.send_bluesound_command('Pause') - @asyncio.coroutine - def async_media_seek(self, position): + async def async_media_seek(self, position): """Send media_seek command to media player.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Play?seek=' + str(float(position))) + return await self.send_bluesound_command( + 'Play?seek={}'.format(float(position))) - @asyncio.coroutine - def async_play_media(self, media_type, media_id, **kwargs): + async def async_play_media(self, media_type, media_id, **kwargs): """ Send the play_media command to the media player. @@ -957,44 +940,40 @@ class BluesoundPlayer(MediaPlayerDevice): url = 'Play?url={}'.format(media_id) if kwargs.get(ATTR_MEDIA_ENQUEUE): - return self.send_bluesound_command(url) + return await self.send_bluesound_command(url) - return self.send_bluesound_command(url) + return await self.send_bluesound_command(url) - @asyncio.coroutine - def async_volume_up(self): + async def async_volume_up(self): """Volume up the media player.""" current_vol = self.volume_level if not current_vol or current_vol < 0: return return self.async_set_volume_level(((current_vol*100)+1)/100) - @asyncio.coroutine - def async_volume_down(self): + async def async_volume_down(self): """Volume down the media player.""" current_vol = self.volume_level if not current_vol or current_vol < 0: return return self.async_set_volume_level(((current_vol*100)-1)/100) - @asyncio.coroutine - def async_set_volume_level(self, volume): + async def async_set_volume_level(self, volume): """Send volume_up command to media player.""" if volume < 0: volume = 0 elif volume > 1: volume = 1 - return self.send_bluesound_command( + return await self.send_bluesound_command( 'Volume?level=' + str(float(volume) * 100)) - @asyncio.coroutine - def async_mute_volume(self, mute): + async def async_mute_volume(self, mute): """Send mute command to media player.""" if mute: volume = self.volume_level if volume > 0: self._lastvol = volume - return self.send_bluesound_command('Volume?level=0') + return await self.send_bluesound_command('Volume?level=0') else: - return self.send_bluesound_command( + return await self.send_bluesound_command( 'Volume?level=' + str(float(self._lastvol) * 100)) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 579f9b62864..a9bea9e4c1d 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -7,8 +7,10 @@ https://home-assistant.io/components/media_player.cast/ # pylint: disable=import-error import logging import threading +from typing import Optional, Tuple import voluptuous as vol +import attr from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.typing import HomeAssistantType, ConfigType @@ -16,17 +18,17 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import (dispatcher_send, async_dispatcher_connect) from homeassistant.components.media_player import ( - MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, + MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_MOVIE, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, - STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP) + EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pychromecast==2.0.0'] +REQUIREMENTS = ['pychromecast==2.1.0'] _LOGGER = logging.getLogger(__name__) @@ -39,23 +41,103 @@ SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY +# Stores a threading.Lock that is held by the internal pychromecast discovery. INTERNAL_DISCOVERY_RUNNING_KEY = 'cast_discovery_running' -# UUID -> CastDevice mapping; cast devices without UUID are not stored +# Stores all ChromecastInfo we encountered through discovery or config as a set +# If we find a chromecast with a new host, the old one will be removed again. +KNOWN_CHROMECAST_INFO_KEY = 'cast_known_chromecasts' +# Stores UUIDs of cast devices that were added as entities. Doesn't store +# None UUIDs. ADDED_CAST_DEVICES_KEY = 'cast_added_cast_devices' -# Stores every discovered (host, port, uuid) -KNOWN_CHROMECASTS_KEY = 'cast_all_chromecasts' +# Dispatcher signal fired with a ChromecastInfo every time we discover a new +# Chromecast or receive it through configuration SIGNAL_CAST_DISCOVERED = 'cast_discovered' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_IGNORE_CEC): [cv.string], + vol.Optional(CONF_IGNORE_CEC, default=[]): vol.All(cv.ensure_list, + [cv.string]) }) +@attr.s(slots=True, frozen=True) +class ChromecastInfo(object): + """Class to hold all data about a chromecast for creating connections. + + This also has the same attributes as the mDNS fields by zeroconf. + """ + + host = attr.ib(type=str) + port = attr.ib(type=int) + uuid = attr.ib(type=Optional[str], converter=attr.converters.optional(str), + default=None) # always convert UUID to string if not None + model_name = attr.ib(type=str, default='') # needed for cast type + friendly_name = attr.ib(type=Optional[str], default=None) + + @property + def is_audio_group(self) -> bool: + """Return if this is an audio group.""" + return self.port != DEFAULT_PORT + + @property + def is_information_complete(self) -> bool: + """Return if all information is filled out.""" + return all(attr.astuple(self)) + + @property + def host_port(self) -> Tuple[str, int]: + """Return the host+port tuple.""" + return self.host, self.port + + +def _fill_out_missing_chromecast_info(info: ChromecastInfo) -> ChromecastInfo: + """Fill out missing attributes of ChromecastInfo using blocking HTTP.""" + if info.is_information_complete or info.is_audio_group: + # We have all information, no need to check HTTP API. Or this is an + # audio group, so checking via HTTP won't give us any new information. + return info + + # Fill out missing information via HTTP dial. + from pychromecast import dial + + http_device_status = dial.get_device_status(info.host) + if http_device_status is None: + # HTTP dial didn't give us any new information. + return info + + return ChromecastInfo( + host=info.host, port=info.port, + uuid=(info.uuid or http_device_status.uuid), + friendly_name=(info.friendly_name or http_device_status.friendly_name), + model_name=(info.model_name or http_device_status.model_name) + ) + + +def _discover_chromecast(hass: HomeAssistantType, info: ChromecastInfo): + if info in hass.data[KNOWN_CHROMECAST_INFO_KEY]: + _LOGGER.debug("Discovered previous chromecast %s", info) + return + + # Either discovered completely new chromecast or a "moved" one. + info = _fill_out_missing_chromecast_info(info) + _LOGGER.debug("Discovered chromecast %s", info) + + if info.uuid is not None: + # Remove previous cast infos with same uuid from known chromecasts. + same_uuid = set(x for x in hass.data[KNOWN_CHROMECAST_INFO_KEY] + if info.uuid == x.uuid) + hass.data[KNOWN_CHROMECAST_INFO_KEY] -= same_uuid + + hass.data[KNOWN_CHROMECAST_INFO_KEY].add(info) + dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info) + + def _setup_internal_discovery(hass: HomeAssistantType) -> None: """Set up the pychromecast internal discovery.""" - hass.data.setdefault(INTERNAL_DISCOVERY_RUNNING_KEY, threading.Lock()) + if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data: + hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock() + if not hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].acquire(blocking=False): # Internal discovery is already running return @@ -65,30 +147,14 @@ def _setup_internal_discovery(hass: HomeAssistantType) -> None: def internal_callback(name): """Called when zeroconf has discovered a new chromecast.""" mdns = listener.services[name] - ip_address, port, uuid, _, _ = mdns - key = (ip_address, port, uuid) - - if key in hass.data[KNOWN_CHROMECASTS_KEY]: - _LOGGER.debug("Discovered previous chromecast %s", mdns) - return - - _LOGGER.debug("Discovered new chromecast %s", mdns) - try: - # pylint: disable=protected-access - chromecast = pychromecast._get_chromecast_from_host( - mdns, blocking=True) - except pychromecast.ChromecastConnectionError: - _LOGGER.debug("Can't set up cast with mDNS info %s. " - "Assuming it's not a Chromecast", mdns) - return - hass.data[KNOWN_CHROMECASTS_KEY][key] = chromecast - dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, chromecast) + _discover_chromecast(hass, ChromecastInfo(*mdns)) _LOGGER.debug("Starting internal pychromecast discovery.") listener, browser = pychromecast.start_discovery(internal_callback) def stop_discovery(event): """Stop discovery of new chromecasts.""" + _LOGGER.debug("Stopping internal pychromecast discovery.") pychromecast.stop_discovery(browser) hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release() @@ -96,40 +162,26 @@ def _setup_internal_discovery(hass: HomeAssistantType) -> None: @callback -def _async_create_cast_device(hass, chromecast): +def _async_create_cast_device(hass: HomeAssistantType, + info: ChromecastInfo): """Create a CastDevice Entity from the chromecast object. - Returns None if the cast device has already been added. Additionally, - automatically updates existing chromecast entities. + Returns None if the cast device has already been added. """ - if chromecast.uuid is None: + if info.uuid is None: # Found a cast without UUID, we don't store it because we won't be able # to update it anyway. - return CastDevice(chromecast) + return CastDevice(info) # Found a cast with UUID added_casts = hass.data[ADDED_CAST_DEVICES_KEY] - old_cast_device = added_casts.get(chromecast.uuid) - if old_cast_device is None: - # -> New cast device - cast_device = CastDevice(chromecast) - added_casts[chromecast.uuid] = cast_device - return cast_device - - old_key = (old_cast_device.cast.host, - old_cast_device.cast.port, - old_cast_device.cast.uuid) - new_key = (chromecast.host, chromecast.port, chromecast.uuid) - - if old_key == new_key: - # Re-discovered with same data, ignore + if info.uuid in added_casts: + # Already added this one, the entity will take care of moved hosts + # itself return None - - # -> Cast device changed host - # Remove old pychromecast.Chromecast from global list, because it isn't - # valid anymore - old_cast_device.async_set_chromecast(chromecast) - return None + # -> New cast device + added_casts.add(info.uuid) + return CastDevice(info) async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, @@ -139,73 +191,274 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, # Import CEC IGNORE attributes pychromecast.IGNORE_CEC += config.get(CONF_IGNORE_CEC, []) - hass.data.setdefault(ADDED_CAST_DEVICES_KEY, {}) - hass.data.setdefault(KNOWN_CHROMECASTS_KEY, {}) + hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set()) + hass.data.setdefault(KNOWN_CHROMECAST_INFO_KEY, set()) - # None -> use discovery; (host, port) -> manually specify chromecast. - want_host = None - if discovery_info: - want_host = (discovery_info.get('host'), discovery_info.get('port')) + info = None + if discovery_info is not None: + info = ChromecastInfo(host=discovery_info['host'], + port=discovery_info['port']) elif CONF_HOST in config: - want_host = (config.get(CONF_HOST), DEFAULT_PORT) + info = ChromecastInfo(host=config[CONF_HOST], + port=DEFAULT_PORT) - enable_discovery = False - if want_host is None: - # We were explicitly told to enable pychromecast discovery. - enable_discovery = True - elif want_host[1] != DEFAULT_PORT: - # We're trying to add a group, so we have to use pychromecast's - # discovery to get the correct friendly name. - enable_discovery = True + @callback + def async_cast_discovered(discover: ChromecastInfo) -> None: + """Callback for when a new chromecast is discovered.""" + if info is not None and info.host_port != discover.host_port: + # Not our requested cast device. + return - if enable_discovery: - @callback - def async_cast_discovered(chromecast): - """Callback for when a new chromecast is discovered.""" - if want_host is not None and \ - (chromecast.host, chromecast.port) != want_host: - return # for groups, only add requested device - cast_device = _async_create_cast_device(hass, chromecast) + cast_device = _async_create_cast_device(hass, discover) + if cast_device is not None: + async_add_devices([cast_device]) - if cast_device is not None: - async_add_devices([cast_device]) - - async_dispatcher_connect(hass, SIGNAL_CAST_DISCOVERED, - async_cast_discovered) - # Re-play the callback for all past chromecasts, store the objects in - # a list to avoid concurrent modification resulting in exception. - for chromecast in list(hass.data[KNOWN_CHROMECASTS_KEY].values()): - async_cast_discovered(chromecast) + async_dispatcher_connect(hass, SIGNAL_CAST_DISCOVERED, + async_cast_discovered) + # Re-play the callback for all past chromecasts, store the objects in + # a list to avoid concurrent modification resulting in exception. + for chromecast in list(hass.data[KNOWN_CHROMECAST_INFO_KEY]): + async_cast_discovered(chromecast) + if info is None or info.is_audio_group: + # If we were a) explicitly told to enable discovery or + # b) have an audio group cast device, we need internal discovery. hass.async_add_job(_setup_internal_discovery, hass) else: - # Manually add a "normal" Chromecast, we can do that without discovery. - try: - chromecast = await hass.async_add_job( - pychromecast.Chromecast, *want_host) - except pychromecast.ChromecastConnectionError as err: - _LOGGER.warning("Can't set up chromecast on %s: %s", - want_host[0], err) + info = await hass.async_add_job(_fill_out_missing_chromecast_info, + info) + if info.friendly_name is None: + # HTTP dial failed, so we won't be able to connect. raise PlatformNotReady - key = (chromecast.host, chromecast.port, chromecast.uuid) - cast_device = _async_create_cast_device(hass, chromecast) - if cast_device is not None: - hass.data[KNOWN_CHROMECASTS_KEY][key] = chromecast - async_add_devices([cast_device]) + hass.async_add_job(_discover_chromecast, hass, info) + + +class CastStatusListener(object): + """Helper class to handle pychromecast status callbacks. + + Necessary because a CastDevice entity can create a new socket client + and therefore callbacks from multiple chromecast connections can + potentially arrive. This class allows invalidating past chromecast objects. + """ + + def __init__(self, cast_device, chromecast): + """Initialize the status listener.""" + self._cast_device = cast_device + self._valid = True + + chromecast.register_status_listener(self) + chromecast.socket_client.media_controller.register_status_listener( + self) + chromecast.register_connection_listener(self) + + def new_cast_status(self, cast_status): + """Called when a new CastStatus is received.""" + if self._valid: + self._cast_device.new_cast_status(cast_status) + + def new_media_status(self, media_status): + """Called when a new MediaStatus is received.""" + if self._valid: + self._cast_device.new_media_status(media_status) + + def new_connection_status(self, connection_status): + """Called when a new ConnectionStatus is received.""" + if self._valid: + self._cast_device.new_connection_status(connection_status) + + def invalidate(self): + """Invalidate this status listener. + + All following callbacks won't be forwarded. + """ + self._valid = False class CastDevice(MediaPlayerDevice): - """Representation of a Cast device on the network.""" + """Representation of a Cast device on the network. - def __init__(self, chromecast): - """Initialize the Cast device.""" - self.cast = None # type: pychromecast.Chromecast + This class is the holder of the pychromecast.Chromecast object and its + socket client. It therefore handles all reconnects and audio group changing + "elected leader" itself. + """ + + def __init__(self, cast_info): + """Initialize the cast device.""" + self._cast_info = cast_info # type: ChromecastInfo + self._chromecast = None # type: Optional[pychromecast.Chromecast] self.cast_status = None self.media_status = None self.media_status_received = None + self._available = False # type: bool + self._status_listener = None # type: Optional[CastStatusListener] - self.async_set_chromecast(chromecast) + async def async_added_to_hass(self): + """Create chromecast object when added to hass.""" + @callback + def async_cast_discovered(discover: ChromecastInfo): + """Callback for changing elected leaders / IP.""" + if self._cast_info.uuid is None: + # We can't handle empty UUIDs + return + if self._cast_info.uuid != discover.uuid: + # Discovered is not our device. + return + _LOGGER.debug("Discovered chromecast with same UUID: %s", discover) + self.hass.async_add_job(self.async_set_cast_info(discover)) + async def async_stop(event): + """Disconnect socket on Home Assistant stop.""" + await self._async_disconnect() + + async_dispatcher_connect(self.hass, SIGNAL_CAST_DISCOVERED, + async_cast_discovered) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) + self.hass.async_add_job(self.async_set_cast_info(self._cast_info)) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect Chromecast object when removed.""" + await self._async_disconnect() + if self._cast_info.uuid is not None: + # Remove the entity from the added casts so that it can dynamically + # be re-added again. + self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid) + + async def async_set_cast_info(self, cast_info): + """Set the cast information and set up the chromecast object.""" + import pychromecast + old_cast_info = self._cast_info + self._cast_info = cast_info + + if self._chromecast is not None: + if old_cast_info.host_port == cast_info.host_port: + # Nothing connection-related updated + return + await self._async_disconnect() + + # Failed connection will unfortunately never raise an exception, it + # will instead just try connecting indefinitely. + # pylint: disable=protected-access + _LOGGER.debug("Connecting to cast device %s", cast_info) + chromecast = await self.hass.async_add_job( + pychromecast._get_chromecast_from_host, attr.astuple(cast_info)) + self._chromecast = chromecast + self._status_listener = CastStatusListener(self, chromecast) + # Initialise connection status as connected because we can only + # register the connection listener *after* the initial connection + # attempt. If the initial connection failed, we would never reach + # this code anyway. + self._available = True + self.cast_status = chromecast.status + self.media_status = chromecast.media_controller.status + _LOGGER.debug("Connection successful!") + self.async_schedule_update_ha_state() + + async def _async_disconnect(self): + """Disconnect Chromecast object if it is set.""" + if self._chromecast is None: + # Can't disconnect if not connected. + return + _LOGGER.debug("Disconnecting from chromecast socket.") + self._available = False + self.async_schedule_update_ha_state() + + await self.hass.async_add_job(self._chromecast.disconnect) + + # Invalidate some attributes + self._chromecast = None + self.cast_status = None + self.media_status = None + self.media_status_received = None + if self._status_listener is not None: + self._status_listener.invalidate() + self._status_listener = None + + self.async_schedule_update_ha_state() + + # ========== Callbacks ========== + def new_cast_status(self, cast_status): + """Handle updates of the cast status.""" + self.cast_status = cast_status + self.schedule_update_ha_state() + + def new_media_status(self, media_status): + """Handle updates of the media status.""" + self.media_status = media_status + self.media_status_received = dt_util.utcnow() + self.schedule_update_ha_state() + + def new_connection_status(self, connection_status): + """Handle updates of connection status.""" + from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED + + new_available = connection_status.status == CONNECTION_STATUS_CONNECTED + if new_available != self._available: + # Connection status callbacks happen often when disconnected. + # Only update state when availability changed to put less pressure + # on state machine. + _LOGGER.debug("Cast device availability changed: %s", + connection_status.status) + self._available = new_available + self.schedule_update_ha_state() + + # ========== Service Calls ========== + def turn_on(self): + """Turn on the cast device.""" + import pychromecast + + if not self._chromecast.is_idle: + # Already turned on + return + + if self._chromecast.app_id is not None: + # Quit the previous app before starting splash screen + self._chromecast.quit_app() + + # The only way we can turn the Chromecast is on is by launching an app + self._chromecast.play_media(CAST_SPLASH, + pychromecast.STREAM_TYPE_BUFFERED) + + def turn_off(self): + """Turn off the cast device.""" + self._chromecast.quit_app() + + def mute_volume(self, mute): + """Mute the volume.""" + self._chromecast.set_volume_muted(mute) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._chromecast.set_volume(volume) + + def media_play(self): + """Send play command.""" + self._chromecast.media_controller.play() + + def media_pause(self): + """Send pause command.""" + self._chromecast.media_controller.pause() + + def media_stop(self): + """Send stop command.""" + self._chromecast.media_controller.stop() + + def media_previous_track(self): + """Send previous track command.""" + self._chromecast.media_controller.rewind() + + def media_next_track(self): + """Send next track command.""" + self._chromecast.media_controller.skip() + + def media_seek(self, position): + """Seek the media to a specific location.""" + self._chromecast.media_controller.seek(position) + + def play_media(self, media_type, media_id, **kwargs): + """Play media from a URL.""" + self._chromecast.media_controller.play_media(media_id, media_type) + + # ========== Properties ========== @property def should_poll(self): """No polling needed.""" @@ -214,23 +467,27 @@ class CastDevice(MediaPlayerDevice): @property def name(self): """Return the name of the device.""" - return self.cast.device.friendly_name + return self._cast_info.friendly_name - # MediaPlayerDevice properties and methods @property def state(self): """Return the state of the player.""" if self.media_status is None: - return STATE_UNKNOWN + return None elif self.media_status.player_is_playing: return STATE_PLAYING elif self.media_status.player_is_paused: return STATE_PAUSED elif self.media_status.player_is_idle: return STATE_IDLE - elif self.cast.is_idle: + elif self._chromecast is not None and self._chromecast.is_idle: return STATE_OFF - return STATE_UNKNOWN + return None + + @property + def available(self): + """Return True if the cast device is connected.""" + return self._available @property def volume_level(self): @@ -255,7 +512,7 @@ class CastDevice(MediaPlayerDevice): elif self.media_status.media_is_tvshow: return MEDIA_TYPE_TVSHOW elif self.media_status.media_is_movie: - return MEDIA_TYPE_VIDEO + return MEDIA_TYPE_MOVIE elif self.media_status.media_is_musictrack: return MEDIA_TYPE_MUSIC return None @@ -318,12 +575,12 @@ class CastDevice(MediaPlayerDevice): @property def app_id(self): """Return the ID of the current running app.""" - return self.cast.app_id + return self._chromecast.app_id if self._chromecast else None @property def app_name(self): """Name of the current running app.""" - return self.cast.app_display_name + return self._chromecast.app_display_name if self._chromecast else None @property def supported_features(self): @@ -334,11 +591,10 @@ class CastDevice(MediaPlayerDevice): def media_position(self): """Position of current playing media in seconds.""" if self.media_status is None or \ - not (self.media_status.player_is_playing or - self.media_status.player_is_paused or - self.media_status.player_is_idle): + not (self.media_status.player_is_playing or + self.media_status.player_is_paused or + self.media_status.player_is_idle): return None - return self.media_status.current_time @property @@ -349,101 +605,7 @@ class CastDevice(MediaPlayerDevice): """ return self.media_status_received - def turn_on(self): - """Turn on the ChromeCast.""" - # The only way we can turn the Chromecast is on is by launching an app - if not self.cast.status or not self.cast.status.is_active_input: - import pychromecast - - if self.cast.app_id: - self.cast.quit_app() - - self.cast.play_media( - CAST_SPLASH, pychromecast.STREAM_TYPE_BUFFERED) - - def turn_off(self): - """Turn Chromecast off.""" - self.cast.quit_app() - - def mute_volume(self, mute): - """Mute the volume.""" - self.cast.set_volume_muted(mute) - - def set_volume_level(self, volume): - """Set volume level, range 0..1.""" - self.cast.set_volume(volume) - - def media_play(self): - """Send play command.""" - self.cast.media_controller.play() - - def media_pause(self): - """Send pause command.""" - self.cast.media_controller.pause() - - def media_stop(self): - """Send stop command.""" - self.cast.media_controller.stop() - - def media_previous_track(self): - """Send previous track command.""" - self.cast.media_controller.rewind() - - def media_next_track(self): - """Send next track command.""" - self.cast.media_controller.skip() - - def media_seek(self, position): - """Seek the media to a specific location.""" - self.cast.media_controller.seek(position) - - def play_media(self, media_type, media_id, **kwargs): - """Play media from a URL.""" - self.cast.media_controller.play_media(media_id, media_type) - - # Implementation of chromecast status_listener methods - def new_cast_status(self, status): - """Handle updates of the cast status.""" - self.cast_status = status - self.schedule_update_ha_state() - - def new_media_status(self, status): - """Handle updates of the media status.""" - self.media_status = status - self.media_status_received = dt_util.utcnow() - self.schedule_update_ha_state() - @property - def unique_id(self) -> str: + def unique_id(self) -> Optional[str]: """Return a unique ID.""" - if self.cast.uuid is not None: - return str(self.cast.uuid) - return None - - @callback - def async_set_chromecast(self, chromecast): - """Set the internal Chromecast object and disconnect the previous.""" - self._async_disconnect() - - self.cast = chromecast - - self.cast.socket_client.receiver_controller.register_status_listener( - self) - self.cast.socket_client.media_controller.register_status_listener(self) - - self.cast_status = self.cast.status - self.media_status = self.cast.media_controller.status - - async def async_will_remove_from_hass(self) -> None: - """Disconnect Chromecast object when removed.""" - self._async_disconnect() - - @callback - def _async_disconnect(self): - """Disconnect Chromecast object if it is set.""" - if self.cast is None: - return - _LOGGER.debug("Disconnecting existing chromecast object") - old_key = (self.cast.host, self.cast.port, self.cast.uuid) - self.hass.data[KNOWN_CHROMECASTS_KEY].pop(old_key) - self.cast.disconnect(blocking=False) + return self._cast_info.uuid diff --git a/homeassistant/components/media_player/channels.py b/homeassistant/components/media_player/channels.py index 480e5152c8e..6b41ace6ce2 100644 --- a/homeassistant/components/media_player/channels.py +++ b/homeassistant/components/media_player/channels.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.media_player import ( MEDIA_TYPE_CHANNEL, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_EPISODE, - MEDIA_TYPE_VIDEO, SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP, + MEDIA_TYPE_MOVIE, SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_PLAY_MEDIA, SUPPORT_SELECT_SOURCE, DOMAIN, PLATFORM_SCHEMA, MediaPlayerDevice) @@ -281,7 +281,7 @@ class ChannelsPlayer(MediaPlayerDevice): if media_type == MEDIA_TYPE_CHANNEL: response = self.client.play_channel(media_id) self.update_state(response) - elif media_type in [MEDIA_TYPE_VIDEO, MEDIA_TYPE_EPISODE, + elif media_type in [MEDIA_TYPE_MOVIE, MEDIA_TYPE_EPISODE, MEDIA_TYPE_TVSHOW]: response = self.client.play_recording(media_id) self.update_state(response) diff --git a/homeassistant/components/media_player/cmus.py b/homeassistant/components/media_player/cmus.py index bcbee5c4ff7..0758b5f3058 100644 --- a/homeassistant/components/media_player/cmus.py +++ b/homeassistant/components/media_player/cmus.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_PASSWORD) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pycmus==0.1.0'] +REQUIREMENTS = ['pycmus==0.1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index 2be7ad431cf..22fe1d005f7 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ from homeassistant.components.media_player import ( - MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, + MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_MOVIE, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, SUPPORT_PLAY, @@ -147,7 +147,7 @@ class DemoYoutubePlayer(AbstractDemoPlayer): @property def media_content_type(self): """Return the content type of current playing media.""" - return MEDIA_TYPE_VIDEO + return MEDIA_TYPE_MOVIE @property def media_duration(self): diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index fe8fc46c24b..74d3c5a0785 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.6.1'] +REQUIREMENTS = ['denonavr==0.7.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index fae18f03cde..0adb02b6a65 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -8,7 +8,7 @@ import voluptuous as vol import requests from homeassistant.components.media_player import ( - MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, + MEDIA_TYPE_TVSHOW, MEDIA_TYPE_MOVIE, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_STOP, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_PLAY, MediaPlayerDevice) @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_DEVICE, CONF_HOST, CONF_NAME, STATE_OFF, STATE_PLAYING, CONF_PORT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['directpy==0.2'] +REQUIREMENTS = ['directpy==0.5'] DEFAULT_DEVICE = '0' DEFAULT_NAME = 'DirecTV Receiver' @@ -154,7 +154,7 @@ class DirecTvDevice(MediaPlayerDevice): """Return the content type of current playing media.""" if 'episodeTitle' in self._current: return MEDIA_TYPE_TVSHOW - return MEDIA_TYPE_VIDEO + return MEDIA_TYPE_MOVIE @property def media_channel(self): diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py index 7b5658c56d9..4f9a4019268 100644 --- a/homeassistant/components/media_player/emby.py +++ b/homeassistant/components/media_player/emby.py @@ -10,7 +10,7 @@ import logging import voluptuous as vol from homeassistant.components.media_player import ( - MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, + MEDIA_TYPE_TVSHOW, MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_SEEK, SUPPORT_STOP, SUPPORT_PREVIOUS_TRACK, MediaPlayerDevice, SUPPORT_PLAY, PLATFORM_SCHEMA) from homeassistant.const import ( @@ -231,7 +231,7 @@ class EmbyDevice(MediaPlayerDevice): if media_type == 'Episode': return MEDIA_TYPE_TVSHOW elif media_type == 'Movie': - return MEDIA_TYPE_VIDEO + return MEDIA_TYPE_MOVIE elif media_type == 'Trailer': return MEDIA_TYPE_TRAILER elif media_type == 'Music': diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 6450b2f5b35..7fa8d5b3fe8 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -8,6 +8,7 @@ import asyncio from collections import OrderedDict from functools import wraps import logging +import socket import urllib import re @@ -19,8 +20,8 @@ from homeassistant.components.media_player import ( SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_PLAY, SUPPORT_VOLUME_STEP, SUPPORT_SHUFFLE_SET, MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, - MEDIA_TYPE_VIDEO, MEDIA_TYPE_CHANNEL, MEDIA_TYPE_PLAYLIST, - MEDIA_PLAYER_SCHEMA, DOMAIN, SUPPORT_TURN_ON) + MEDIA_TYPE_MOVIE, MEDIA_TYPE_VIDEO, MEDIA_TYPE_CHANNEL, + MEDIA_TYPE_PLAYLIST, MEDIA_PLAYER_SCHEMA, DOMAIN, SUPPORT_TURN_ON) from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_HOST, CONF_NAME, CONF_PORT, CONF_PROXY_SSL, CONF_USERNAME, CONF_PASSWORD, @@ -67,12 +68,14 @@ MEDIA_TYPES = { 'video': MEDIA_TYPE_VIDEO, 'set': MEDIA_TYPE_PLAYLIST, 'musicvideo': MEDIA_TYPE_VIDEO, - 'movie': MEDIA_TYPE_VIDEO, + 'movie': MEDIA_TYPE_MOVIE, 'tvshow': MEDIA_TYPE_TVSHOW, 'season': MEDIA_TYPE_TVSHOW, 'episode': MEDIA_TYPE_TVSHOW, # Type 'channel' is used for radio or tv streams from pvr 'channel': MEDIA_TYPE_CHANNEL, + # Type 'audio' is used for audio media, that Kodi couldn't scroblle + 'audio': MEDIA_TYPE_MUSIC, } SUPPORT_KODI = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ @@ -155,13 +158,29 @@ def _check_deprecated_turn_off(hass, turn_off_action): def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Kodi platform.""" if DATA_KODI not in hass.data: - hass.data[DATA_KODI] = [] - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - tcp_port = config.get(CONF_TCP_PORT) - encryption = config.get(CONF_PROXY_SSL) - websocket = config.get(CONF_ENABLE_WEBSOCKET) + hass.data[DATA_KODI] = dict() + + # Is this a manual configuration? + if discovery_info is None: + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + tcp_port = config.get(CONF_TCP_PORT) + encryption = config.get(CONF_PROXY_SSL) + websocket = config.get(CONF_ENABLE_WEBSOCKET) + else: + name = "{} ({})".format(DEFAULT_NAME, discovery_info.get('hostname')) + host = discovery_info.get('host') + port = discovery_info.get('port') + tcp_port = DEFAULT_TCP_PORT + encryption = DEFAULT_PROXY_SSL + websocket = DEFAULT_ENABLE_WEBSOCKET + + # Only add a device once, so discovered devices do not override manual + # config. + ip_addr = socket.gethostbyname(host) + if ip_addr in hass.data[DATA_KODI]: + return entity = KodiDevice( hass, @@ -173,7 +192,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): turn_off_action=config.get(CONF_TURN_OFF_ACTION), timeout=config.get(CONF_TIMEOUT), websocket=websocket) - hass.data[DATA_KODI].append(entity) + hass.data[DATA_KODI][ip_addr] = entity async_add_devices([entity], update_before_add=True) @asyncio.coroutine @@ -187,10 +206,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if key != 'entity_id'} entity_ids = service.data.get('entity_id') if entity_ids: - target_players = [player for player in hass.data[DATA_KODI] + target_players = [player + for player in hass.data[DATA_KODI].values() if player.entity_id in entity_ids] else: - target_players = hass.data[DATA_KODI] + target_players = hass.data[DATA_KODI].values() update_tasks = [] for player in target_players: @@ -274,6 +294,7 @@ class KodiDevice(MediaPlayerDevice): # Register notification listeners self._ws_server.Player.OnPause = self.async_on_speed_event self._ws_server.Player.OnPlay = self.async_on_speed_event + self._ws_server.Player.OnResume = self.async_on_speed_event self._ws_server.Player.OnSpeedChanged = self.async_on_speed_event self._ws_server.Player.OnStop = self.async_on_stop self._ws_server.Application.OnVolumeChanged = \ @@ -372,7 +393,7 @@ class KodiDevice(MediaPlayerDevice): if not self._players: return STATE_IDLE - if self._properties['speed'] == 0 and not self._properties['live']: + if self._properties['speed'] == 0: return STATE_PAUSED return STATE_PLAYING @@ -480,7 +501,12 @@ class KodiDevice(MediaPlayerDevice): @property def media_content_type(self): - """Content type of current playing media.""" + """Content type of current playing media. + + If the media type cannot be detected, the player type is used. + """ + if MEDIA_TYPES.get(self._item.get('type')) is None and self._players: + return MEDIA_TYPES.get(self._players[0]['type']) return MEDIA_TYPES.get(self._item.get('type')) @property @@ -516,8 +542,8 @@ class KodiDevice(MediaPlayerDevice): def media_title(self): """Title of current playing media.""" # find a string we can use as a title - return self._item.get( - 'title', self._item.get('label', self._item.get('file'))) + item = self._item + return item.get('title') or item.get('label') or item.get('file') @property def media_series_title(self): diff --git a/homeassistant/components/media_player/liveboxplaytv.py b/homeassistant/components/media_player/liveboxplaytv.py index 8093f0d3dbe..4fe4da5a942 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -22,7 +22,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['liveboxplaytv==2.0.2', 'pyteleloisirs==3.3'] +REQUIREMENTS = ['liveboxplaytv==2.0.2', 'pyteleloisirs==3.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/mediaroom.py b/homeassistant/components/media_player/mediaroom.py index 3cf0ecdb232..f5b7567aa34 100644 --- a/homeassistant/components/media_player/mediaroom.py +++ b/homeassistant/components/media_player/mediaroom.py @@ -8,135 +8,193 @@ import logging import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.media_player import ( - MEDIA_TYPE_CHANNEL, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, - SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_STOP, PLATFORM_SCHEMA, - SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_PLAY, - SUPPORT_VOLUME_STEP, SUPPORT_VOLUME_MUTE, - MediaPlayerDevice) + MEDIA_TYPE_CHANNEL, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, SUPPORT_STOP, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, + SUPPORT_PREVIOUS_TRACK, SUPPORT_PLAY, SUPPORT_VOLUME_STEP, + SUPPORT_VOLUME_MUTE, MediaPlayerDevice, +) +from homeassistant.helpers.dispatcher import ( + dispatcher_send, async_dispatcher_connect +) from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_OPTIMISTIC, CONF_TIMEOUT, - STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, - STATE_ON) + CONF_HOST, CONF_NAME, CONF_OPTIMISTIC, STATE_OFF, + CONF_TIMEOUT, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, + STATE_UNAVAILABLE, EVENT_HOMEASSISTANT_STOP +) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pymediaroom==0.5'] + +REQUIREMENTS = ['pymediaroom==0.6.3'] _LOGGER = logging.getLogger(__name__) -NOTIFICATION_TITLE = 'Mediaroom Media Player Setup' -NOTIFICATION_ID = 'mediaroom_notification' DEFAULT_NAME = 'Mediaroom STB' DEFAULT_TIMEOUT = 9 DATA_MEDIAROOM = "mediaroom_known_stb" +DISCOVERY_MEDIAROOM = "mediaroom_discovery_installed" +SIGNAL_STB_NOTIFY = 'mediaroom_stb_discovered' +SUPPORT_MEDIAROOM = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF \ + | SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_PLAY_MEDIA \ + | SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK \ + | SUPPORT_PLAY -SUPPORT_MEDIAROOM = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ - SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | \ - SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + } +) -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Mediaroom platform.""" - hosts = [] - known_hosts = hass.data.get(DATA_MEDIAROOM) if known_hosts is None: known_hosts = hass.data[DATA_MEDIAROOM] = [] - host = config.get(CONF_HOST, None) - if host is None: - _LOGGER.info("Trying to discover Mediaroom STB") + if host: + async_add_devices([MediaroomDevice(host=host, + device_id=None, + optimistic=config[CONF_OPTIMISTIC], + timeout=config[CONF_TIMEOUT])]) + hass.data[DATA_MEDIAROOM].append(host) - from pymediaroom import Remote + _LOGGER.debug("Trying to discover Mediaroom STB") - host = Remote.discover(known_hosts) - if host is None: - _LOGGER.warning("Can't find any STB") + def callback_notify(notify): + """Process NOTIFY message from STB.""" + if notify.ip_address in hass.data[DATA_MEDIAROOM]: + dispatcher_send(hass, SIGNAL_STB_NOTIFY, notify) return - hosts.append(host) - known_hosts.append(host) - stbs = [] + _LOGGER.debug("Discovered new stb %s", notify.ip_address) + hass.data[DATA_MEDIAROOM].append(notify.ip_address) + new_stb = MediaroomDevice( + host=notify.ip_address, device_id=notify.device_uuid, + optimistic=False + ) + async_add_devices([new_stb]) - try: - for host in hosts: - stbs.append(MediaroomDevice( - config.get(CONF_NAME), - host, - config.get(CONF_OPTIMISTIC), - config.get(CONF_TIMEOUT) - )) + if not config[CONF_OPTIMISTIC]: + from pymediaroom import install_mediaroom_protocol - except ConnectionRefusedError: - hass.components.persistent_notification.create( - 'Error: Unable to initialize mediaroom at {}
' - 'Check its network connection or consider ' - 'using auto discovery.
' - 'You will need to restart hass after fixing.' - ''.format(host), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) + already_installed = hass.data.get(DISCOVERY_MEDIAROOM, None) + if not already_installed: + hass.data[DISCOVERY_MEDIAROOM] = await install_mediaroom_protocol( + responses_callback=callback_notify) - add_devices(stbs) + @callback + def stop_discovery(event): + """Stop discovery of new mediaroom STB's.""" + _LOGGER.debug("Stopping internal pymediaroom discovery.") + hass.data[DISCOVERY_MEDIAROOM].close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + stop_discovery) + + _LOGGER.debug("Auto discovery installed") class MediaroomDevice(MediaPlayerDevice): """Representation of a Mediaroom set-up-box on the network.""" - def __init__(self, name, host, optimistic=False, timeout=DEFAULT_TIMEOUT): + def set_state(self, mediaroom_state): + """Helper method to map pymediaroom states to HA states.""" + from pymediaroom import State + + state_map = { + State.OFF: STATE_OFF, + State.STANDBY: STATE_STANDBY, + State.PLAYING_LIVE_TV: STATE_PLAYING, + State.PLAYING_RECORDED_TV: STATE_PLAYING, + State.PLAYING_TIMESHIFT_TV: STATE_PLAYING, + State.STOPPED: STATE_PAUSED, + State.UNKNOWN: STATE_UNAVAILABLE + } + + self._state = state_map[mediaroom_state] + + def __init__(self, host, device_id, optimistic=False, + timeout=DEFAULT_TIMEOUT): """Initialize the device.""" from pymediaroom import Remote - self.stb = Remote(host, timeout=timeout) - _LOGGER.info( - "Found %s at %s%s", name, host, - " - I'm optimistic" if optimistic else "") - self._name = name - self._is_standby = not optimistic - self._current = None + self.host = host + self.stb = Remote(host) + _LOGGER.info("Found STB at %s%s", host, + " - I'm optimistic" if optimistic else "") + self._channel = None self._optimistic = optimistic - self._state = STATE_STANDBY + self._state = STATE_PLAYING if optimistic else STATE_STANDBY + self._name = 'Mediaroom {}'.format(device_id if device_id else host) + self._available = True + if device_id: + self._unique_id = device_id + else: + self._unique_id = None - def update(self): + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + async def async_added_to_hass(self): """Retrieve latest state.""" - if not self._optimistic: - self._is_standby = self.stb.get_standby() - if self._is_standby: - self._state = STATE_STANDBY - elif self._state not in [STATE_PLAYING, STATE_PAUSED]: - self._state = STATE_PLAYING - _LOGGER.debug( - "%s(%s) is [%s]", - self._name, self.stb.stb_ip, self._state) + async def async_notify_received(notify): + """Process STB state from NOTIFY message.""" + stb_state = self.stb.notify_callback(notify) + # stb_state is None in case the notify is not from the current stb + if not stb_state: + return + self.set_state(stb_state) + _LOGGER.debug("STB(%s) is [%s]", self.host, self._state) + self._available = True + self.async_schedule_update_ha_state() - def play_media(self, media_type, media_id, **kwargs): + async_dispatcher_connect(self.hass, SIGNAL_STB_NOTIFY, + async_notify_received) + + async def async_play_media(self, media_type, media_id, **kwargs): """Play media.""" - _LOGGER.debug( - "%s(%s) Play media: %s (%s)", - self._name, self.stb.stb_ip, media_id, media_type) + from pymediaroom import PyMediaroomError + + _LOGGER.debug("STB(%s) Play media: %s (%s)", self.stb.stb_ip, + media_id, media_type) if media_type != MEDIA_TYPE_CHANNEL: _LOGGER.error('invalid media type') return - if media_id.isdigit(): - media_id = int(media_id) - else: + if not media_id.isdigit(): + _LOGGER.error("media_id must be a channel number") return - self.stb.send_cmd(media_id) - self._state = STATE_PLAYING + + try: + await self.stb.send_cmd(int(media_id)) + if self._optimistic: + self._state = STATE_PLAYING + self._available = True + except PyMediaroomError: + self._available = False + self.async_schedule_update_ha_state() + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id @property def name(self): """Return the name of the device.""" return self._name - # MediaPlayerDevice properties and methods @property def state(self): """Return the state of the device.""" @@ -152,50 +210,120 @@ class MediaroomDevice(MediaPlayerDevice): """Return the content type of current playing media.""" return MEDIA_TYPE_CHANNEL - def turn_on(self): + @property + def media_channel(self): + """Channel currently playing.""" + return self._channel + + async def async_turn_on(self): """Turn on the receiver.""" - self.stb.send_cmd('Power') - self._state = STATE_ON + from pymediaroom import PyMediaroomError + try: + self.set_state(await self.stb.turn_on()) + if self._optimistic: + self._state = STATE_PLAYING + self._available = True + except PyMediaroomError: + self._available = False + self.async_schedule_update_ha_state() - def turn_off(self): + async def async_turn_off(self): """Turn off the receiver.""" - self.stb.send_cmd('Power') - self._state = STATE_STANDBY + from pymediaroom import PyMediaroomError + try: + self.set_state(await self.stb.turn_off()) + if self._optimistic: + self._state = STATE_STANDBY + self._available = True + except PyMediaroomError: + self._available = False + self.async_schedule_update_ha_state() - def media_play(self): + async def async_media_play(self): """Send play command.""" - _LOGGER.debug("media_play()") - self.stb.send_cmd('PlayPause') - self._state = STATE_PLAYING + from pymediaroom import PyMediaroomError + try: + _LOGGER.debug("media_play()") + await self.stb.send_cmd('PlayPause') + if self._optimistic: + self._state = STATE_PLAYING + self._available = True + except PyMediaroomError: + self._available = False + self.async_schedule_update_ha_state() - def media_pause(self): + async def async_media_pause(self): """Send pause command.""" - self.stb.send_cmd('PlayPause') - self._state = STATE_PAUSED + from pymediaroom import PyMediaroomError + try: + await self.stb.send_cmd('PlayPause') + if self._optimistic: + self._state = STATE_PAUSED + self._available = True + except PyMediaroomError: + self._available = False + self.async_schedule_update_ha_state() - def media_stop(self): + async def async_media_stop(self): """Send stop command.""" - self.stb.send_cmd('Stop') - self._state = STATE_PAUSED + from pymediaroom import PyMediaroomError + try: + await self.stb.send_cmd('Stop') + if self._optimistic: + self._state = STATE_PAUSED + self._available = True + except PyMediaroomError: + self._available = False + self.async_schedule_update_ha_state() - def media_previous_track(self): + async def async_media_previous_track(self): """Send Program Down command.""" - self.stb.send_cmd('ProgDown') - self._state = STATE_PLAYING + from pymediaroom import PyMediaroomError + try: + await self.stb.send_cmd('ProgDown') + if self._optimistic: + self._state = STATE_PLAYING + self._available = True + except PyMediaroomError: + self._available = False + self.async_schedule_update_ha_state() - def media_next_track(self): + async def async_media_next_track(self): """Send Program Up command.""" - self.stb.send_cmd('ProgUp') - self._state = STATE_PLAYING + from pymediaroom import PyMediaroomError + try: + await self.stb.send_cmd('ProgUp') + if self._optimistic: + self._state = STATE_PLAYING + self._available = True + except PyMediaroomError: + self._available = False + self.async_schedule_update_ha_state() - def volume_up(self): + async def async_volume_up(self): """Send volume up command.""" - self.stb.send_cmd('VolUp') + from pymediaroom import PyMediaroomError + try: + await self.stb.send_cmd('VolUp') + self._available = True + except PyMediaroomError: + self._available = False + self.async_schedule_update_ha_state() - def volume_down(self): + async def async_volume_down(self): """Send volume up command.""" - self.stb.send_cmd('VolDown') + from pymediaroom import PyMediaroomError + try: + await self.stb.send_cmd('VolDown') + except PyMediaroomError: + self._available = False + self.async_schedule_update_ha_state() - def mute_volume(self, mute): + async def async_mute_volume(self, mute): """Send mute command.""" - self.stb.send_cmd('Mute') + from pymediaroom import PyMediaroomError + try: + await self.stb.send_cmd('Mute') + except PyMediaroomError: + self._available = False + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/media_player/mpchc.py b/homeassistant/components/media_player/mpchc.py index cc195db2590..a375a585ad4 100644 --- a/homeassistant/components/media_player/mpchc.py +++ b/homeassistant/components/media_player/mpchc.py @@ -155,8 +155,8 @@ class MpcHcDevice(MediaPlayerDevice): def media_next_track(self): """Send next track command.""" - self._send_command(921) + self._send_command(920) def media_previous_track(self): """Send previous track command.""" - self._send_command(920) + self._send_command(919) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 81a18ab93c5..04dd1ac5f2e 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -23,7 +23,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['python-mpd2==0.5.5'] +REQUIREMENTS = ['python-mpd2==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index 432d9ce108f..71b74868544 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -13,7 +13,8 @@ import voluptuous as vol from homeassistant.components.media_player import ( SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_SELECT_SOURCE, SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA) + SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE, SUPPORT_PLAY, + MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import (STATE_OFF, STATE_ON, CONF_HOST, CONF_NAME) import homeassistant.helpers.config_validation as cv @@ -22,11 +23,14 @@ REQUIREMENTS = ['onkyo-eiscp==1.2.4'] _LOGGER = logging.getLogger(__name__) CONF_SOURCES = 'sources' +CONF_MAX_VOLUME = 'max_volume' DEFAULT_NAME = 'Onkyo Receiver' +SUPPORTED_MAX_VOLUME = 80 SUPPORT_ONKYO = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ - SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE | SUPPORT_PLAY + SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY KNOWN_HOSTS = [] # type: List[str] DEFAULT_SOURCES = {'tv': 'TV', 'bd': 'Bluray', 'game': 'Game', 'aux1': 'Aux1', @@ -38,10 +42,40 @@ DEFAULT_SOURCES = {'tv': 'TV', 'bd': 'Bluray', 'game': 'Game', 'aux1': 'Aux1', PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MAX_VOLUME, default=SUPPORTED_MAX_VOLUME): + vol.All(vol.Coerce(int), vol.Range(min=1, max=SUPPORTED_MAX_VOLUME)), vol.Optional(CONF_SOURCES, default=DEFAULT_SOURCES): {cv.string: cv.string}, }) +TIMEOUT_MESSAGE = 'Timeout waiting for response.' + + +def determine_zones(receiver): + """Determine what zones are available for the receiver.""" + out = { + "zone2": False, + "zone3": False, + } + try: + _LOGGER.debug("Checking for zone 2 capability") + receiver.raw("ZPW") + out["zone2"] = True + except ValueError as error: + if str(error) != TIMEOUT_MESSAGE: + raise error + _LOGGER.debug("Zone 2 timed out, assuming no functionality") + try: + _LOGGER.debug("Checking for zone 3 capability") + receiver.raw("PW3") + out["zone3"] = True + except ValueError as error: + if str(error) != TIMEOUT_MESSAGE: + raise error + _LOGGER.debug("Zone 3 timed out, assuming no functionality") + + return out + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Onkyo platform.""" @@ -53,10 +87,31 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if CONF_HOST in config and host not in KNOWN_HOSTS: try: + receiver = eiscp.eISCP(host) hosts.append(OnkyoDevice( - eiscp.eISCP(host), config.get(CONF_SOURCES), - name=config.get(CONF_NAME))) + receiver, + config.get(CONF_SOURCES), + name=config.get(CONF_NAME), + max_volume=config.get(CONF_MAX_VOLUME), + )) KNOWN_HOSTS.append(host) + + zones = determine_zones(receiver) + + # Add Zone2 if available + if zones["zone2"]: + _LOGGER.debug("Setting up zone 2") + hosts.append(OnkyoDeviceZone( + "2", receiver, + config.get(CONF_SOURCES), + name="{} Zone 2".format(config[CONF_NAME]))) + # Add Zone3 if available + if zones["zone3"]: + _LOGGER.debug("Setting up zone 3") + hosts.append(OnkyoDeviceZone( + "3", receiver, + config.get(CONF_SOURCES), + name="{} Zone 3".format(config[CONF_NAME]))) except OSError: _LOGGER.error("Unable to connect to receiver at %s", host) else: @@ -70,7 +125,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class OnkyoDevice(MediaPlayerDevice): """Representation of an Onkyo device.""" - def __init__(self, receiver, sources, name=None): + def __init__(self, receiver, sources, name=None, + max_volume=SUPPORTED_MAX_VOLUME): """Initialize the Onkyo Receiver.""" self._receiver = receiver self._muted = False @@ -78,6 +134,7 @@ class OnkyoDevice(MediaPlayerDevice): self._pwstate = STATE_OFF self._name = name or '{}_{}'.format( receiver.info['model_name'], receiver.info['identifier']) + self._max_volume = max_volume self._current_source = None self._source_list = list(sources.values()) self._source_mapping = sources @@ -98,8 +155,9 @@ class OnkyoDevice(MediaPlayerDevice): return result def update(self): - """Get the latest details from the device.""" + """Get the latest state from the device.""" status = self.command('system-power query') + if not status: return if status[1] == 'on': @@ -107,9 +165,130 @@ class OnkyoDevice(MediaPlayerDevice): else: self._pwstate = STATE_OFF return + volume_raw = self.command('volume query') mute_raw = self.command('audio-muting query') current_source_raw = self.command('input-selector query') + + if not (volume_raw and mute_raw and current_source_raw): + return + + # eiscp can return string or tuple. Make everything tuples. + if isinstance(current_source_raw[1], str): + current_source_tuples = \ + (current_source_raw[0], (current_source_raw[1],)) + else: + current_source_tuples = current_source_raw + + for source in current_source_tuples[1]: + if source in self._source_mapping: + self._current_source = self._source_mapping[source] + break + else: + self._current_source = '_'.join( + [i for i in current_source_tuples[1]]) + self._muted = bool(mute_raw[1] == 'on') + self._volume = volume_raw[1] / self._max_volume + + @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._pwstate + + @property + def volume_level(self): + """Return the volume level of the media player (0..1).""" + return self._volume + + @property + def is_volume_muted(self): + """Return boolean indicating mute status.""" + return self._muted + + @property + def supported_features(self): + """Return media player features that are supported.""" + return SUPPORT_ONKYO + + @property + def source(self): + """Return the current input source of the device.""" + return self._current_source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + def turn_off(self): + """Turn the media player off.""" + self.command('system-power standby') + + def set_volume_level(self, volume): + """ + Set volume level, input is range 0..1. + + Onkyo ranges from 1-80 however 80 is usually far too loud + so allow the user to specify the upper range with CONF_MAX_VOLUME + """ + self.command('volume {}'.format(int(volume * self._max_volume))) + + def volume_up(self): + """Increase volume by 1 step.""" + self.command('volume level-up') + + def volume_down(self): + """Decrease volume by 1 step.""" + self.command('volume level-down') + + def mute_volume(self, mute): + """Mute (true) or unmute (false) media player.""" + if mute: + self.command('audio-muting on') + else: + self.command('audio-muting off') + + def turn_on(self): + """Turn the media player on.""" + self.command('system-power on') + + def select_source(self, source): + """Set the input source.""" + if source in self._source_list: + source = self._reverse_mapping[source] + self.command('input-selector {}'.format(source)) + + +class OnkyoDeviceZone(OnkyoDevice): + """Representation of an Onkyo device's extra zone.""" + + def __init__(self, zone, receiver, sources, name=None): + """Initialize the Zone with the zone identifier.""" + self._zone = zone + super().__init__(receiver, sources, name) + + def update(self): + """Get the latest state from the device.""" + status = self.command('zone{}.power=query'.format(self._zone)) + + if not status: + return + if status[1] == 'on': + self._pwstate = STATE_ON + else: + self._pwstate = STATE_OFF + return + + volume_raw = self.command('zone{}.volume=query'.format(self._zone)) + mute_raw = self.command('zone{}.muting=query'.format(self._zone)) + current_source_raw = self.command( + 'zone{}.selector=query'.format(self._zone)) + if not (volume_raw and mute_raw and current_source_raw): return @@ -130,62 +309,35 @@ class OnkyoDevice(MediaPlayerDevice): self._muted = bool(mute_raw[1] == 'on') self._volume = volume_raw[1] / 80.0 - @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._pwstate - - @property - def volume_level(self): - """Return the volume level of the media player (0..1).""" - return self._volume - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._muted - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_ONKYO - - @property - def source(self): - """Return the current input source of the device.""" - return self._current_source - - @property - def source_list(self): - """List of available input sources.""" - return self._source_list - def turn_off(self): - """Turn off media player.""" - self.command('system-power standby') + """Turn the media player off.""" + self.command('zone{}.power=standby'.format(self._zone)) def set_volume_level(self, volume): """Set volume level, input is range 0..1. Onkyo ranges from 1-80.""" - self.command('volume {}'.format(int(volume*80))) + self.command('zone{}.volume={}'.format(self._zone, int(volume*80))) + + def volume_up(self): + """Increase volume by 1 step.""" + self.command('zone{}.volume=level-up'.format(self._zone)) + + def volume_down(self): + """Decrease volume by 1 step.""" + self.command('zone{}.volume=level-down'.format(self._zone)) def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" if mute: - self.command('audio-muting on') + self.command('zone{}.muting=on'.format(self._zone)) else: - self.command('audio-muting off') + self.command('zone{}.muting=off'.format(self._zone)) def turn_on(self): """Turn the media player on.""" - self.command('system-power on') + self.command('zone{}.power=on'.format(self._zone)) def select_source(self, source): """Set the input source.""" if source in self._source_list: source = self._reverse_mapping[source] - self.command('input-selector {}'.format(source)) + self.command('zone{}.selector={}'.format(self._zone, source)) diff --git a/homeassistant/components/media_player/panasonic_viera.py b/homeassistant/components/media_player/panasonic_viera.py index 39e5f81b71d..db60de922d9 100644 --- a/homeassistant/components/media_player/panasonic_viera.py +++ b/homeassistant/components/media_player/panasonic_viera.py @@ -56,8 +56,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = discovery_info.get('name') host = discovery_info.get('host') port = discovery_info.get('port') + udn = discovery_info.get('udn') + if udn and udn.startswith('uuid:'): + uuid = udn[len('uuid:'):] + else: + uuid = None remote = RemoteControl(host, port) - add_devices([PanasonicVieraTVDevice(mac, name, remote)]) + add_devices([PanasonicVieraTVDevice(mac, name, remote, uuid)]) return True host = config.get(CONF_HOST) @@ -70,19 +75,25 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class PanasonicVieraTVDevice(MediaPlayerDevice): """Representation of a Panasonic Viera TV.""" - def __init__(self, mac, name, remote): + def __init__(self, mac, name, remote, uuid=None): """Initialize the Panasonic device.""" import wakeonlan # Save a reference to the imported class self._wol = wakeonlan self._mac = mac self._name = name + self._uuid = uuid self._muted = False self._playing = True self._state = STATE_UNKNOWN self._remote = remote self._volume = 0 + @property + def unique_id(self) -> str: + """Return the unique ID of this Viera TV.""" + return self._uuid + def update(self): """Retrieve the latest data.""" try: @@ -138,20 +149,20 @@ class PanasonicVieraTVDevice(MediaPlayerDevice): def turn_off(self): """Turn off media player.""" if self._state != STATE_OFF: - self.send_key('NRC_POWER-ONOFF') + self._remote.turn_off() self._state = STATE_OFF def volume_up(self): """Volume up the media player.""" - self.send_key('NRC_VOLUP-ONOFF') + self._remote.volume_up() def volume_down(self): """Volume down media player.""" - self.send_key('NRC_VOLDOWN-ONOFF') + self._remote.volume_down() def mute_volume(self, mute): """Send mute command.""" - self.send_key('NRC_MUTE-ONOFF') + self._remote.set_mute(mute) def set_volume_level(self, volume): """Set volume level, range 0..1.""" @@ -172,20 +183,20 @@ class PanasonicVieraTVDevice(MediaPlayerDevice): def media_play(self): """Send play command.""" self._playing = True - self.send_key('NRC_PLAY-ONOFF') + self._remote.media_play() def media_pause(self): """Send media pause command to media player.""" self._playing = False - self.send_key('NRC_PAUSE-ONOFF') + self._remote.media_pause() def media_next_track(self): """Send next track command.""" - self.send_key('NRC_FF-ONOFF') + self._remote.media_next_track() def media_previous_track(self): """Send the previous track command.""" - self.send_key('NRC_REW-ONOFF') + self._remote.media_previous_track() def play_media(self, media_type, media_id, **kwargs): """Play media.""" diff --git a/homeassistant/components/media_player/philips_js.py b/homeassistant/components/media_player/philips_js.py index 29d336e4d7a..01d63e0b6c8 100644 --- a/homeassistant/components/media_player/philips_js.py +++ b/homeassistant/components/media_player/philips_js.py @@ -13,20 +13,22 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.media_player import ( PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_PLAY, MediaPlayerDevice) + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, + SUPPORT_PLAY, MediaPlayerDevice) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_API_VERSION, STATE_OFF, STATE_ON, STATE_UNKNOWN) from homeassistant.helpers.script import Script from homeassistant.util import Throttle -REQUIREMENTS = ['ha-philipsjs==0.0.2'] +REQUIREMENTS = ['ha-philipsjs==0.0.4'] _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) SUPPORT_PHILIPS_JS = SUPPORT_TURN_OFF | SUPPORT_VOLUME_STEP | \ - SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE + SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_SELECT_SOURCE SUPPORT_PHILIPS_JS_TV = SUPPORT_PHILIPS_JS | SUPPORT_NEXT_TRACK | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY @@ -165,6 +167,10 @@ class PhilipsTV(MediaPlayerDevice): if not self._tv.on: self._state = STATE_OFF + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._tv.setVolume(volume) + def media_previous_track(self): """Send rewind command.""" self._tv.sendKey('Previous') @@ -189,12 +195,10 @@ class PhilipsTV(MediaPlayerDevice): self._volume = self._tv.volume self._muted = self._tv.muted if self._tv.source_id: - src = self._tv.sources.get(self._tv.source_id, None) - if src: - self._source = src.get('name', None) + self._source = self._tv.getSourceName(self._tv.source_id) if self._tv.sources and not self._source_list: - for srcid in sorted(self._tv.sources): - srcname = self._tv.sources.get(srcid, dict()).get('name', None) + for srcid in self._tv.sources: + srcname = self._tv.getSourceName(srcid) self._source_list.append(srcname) self._source_mapping[srcname] = srcid if self._tv.on: diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 48e532074f7..6690382846f 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant import util from homeassistant.components.media_player import ( - MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, PLATFORM_SCHEMA, + MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_MOVIE, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) @@ -23,6 +23,8 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_utc_time_change from homeassistant.util.json import load_json, save_json +from homeassistant.util import dt as dt_util + REQUIREMENTS = ['plexapi==3.0.6'] @@ -38,6 +40,8 @@ CONF_INCLUDE_NON_CLIENTS = 'include_non_clients' CONF_USE_EPISODE_ART = 'use_episode_art' CONF_USE_CUSTOM_ENTITY_IDS = 'use_custom_entity_ids' CONF_SHOW_ALL_CONTROLS = 'show_all_controls' +CONF_REMOVE_UNAVAILABLE_CLIENTS = 'remove_unavailable_clients' +CONF_CLIENT_REMOVE_INTERVAL = 'client_remove_interval' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_INCLUDE_NON_CLIENTS, default=False): @@ -46,6 +50,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ cv.boolean, vol.Optional(CONF_USE_CUSTOM_ENTITY_IDS, default=False): cv.boolean, + vol.Optional(CONF_REMOVE_UNAVAILABLE_CLIENTS, default=True): + cv.boolean, + vol.Optional(CONF_CLIENT_REMOVE_INTERVAL, default=timedelta(seconds=600)): + vol.All(cv.time_period, cv.positive_timedelta), }) PLEX_DATA = "plex" @@ -184,6 +192,7 @@ def setup_plexserver( else: plex_clients[machine_identifier].refresh(None, session) + clients_to_remove = [] for client in plex_clients.values(): # force devices to idle that do not have a valid session if client.session is None: @@ -192,6 +201,18 @@ def setup_plexserver( client.set_availability(client.machine_identifier in available_client_ids) + if not config.get(CONF_REMOVE_UNAVAILABLE_CLIENTS) \ + or client.available: + continue + + if (dt_util.utcnow() - client.marked_unavailable) >= \ + (config.get(CONF_CLIENT_REMOVE_INTERVAL)): + hass.add_job(client.async_remove()) + clients_to_remove.append(client.machine_identifier) + + while clients_to_remove: + del plex_clients[clients_to_remove.pop()] + if new_plex_clients: add_devices_callback(new_plex_clients) @@ -266,6 +287,7 @@ class PlexClient(MediaPlayerDevice): self._app_name = '' self._device = None self._available = False + self._marked_unavailable = None self._device_protocol_capabilities = None self._is_player_active = False self._is_player_available = False @@ -418,6 +440,11 @@ class PlexClient(MediaPlayerDevice): """Set the device as available/unavailable noting time.""" if not available: self._clear_media_details() + if self._marked_unavailable is None: + self._marked_unavailable = dt_util.utcnow() + else: + self._marked_unavailable = None + self._available = available def _set_player_state(self): @@ -453,7 +480,7 @@ class PlexClient(MediaPlayerDevice): self._media_episode = str(self._session.index).zfill(2) elif self._session_type == 'movie': - self._media_content_type = MEDIA_TYPE_VIDEO + self._media_content_type = MEDIA_TYPE_MOVIE if self._session.year is not None and \ self._media_title is not None: self._media_title += ' (' + str(self._session.year) + ')' @@ -506,6 +533,11 @@ class PlexClient(MediaPlayerDevice): """Return the device, if any.""" return self._device + @property + def marked_unavailable(self): + """Return time device was marked unavailable.""" + return self._marked_unavailable + @property def session(self): """Return the session, if any.""" @@ -544,7 +576,7 @@ class PlexClient(MediaPlayerDevice): elif self._session_type == 'episode': return MEDIA_TYPE_TVSHOW elif self._session_type == 'movie': - return MEDIA_TYPE_VIDEO + return MEDIA_TYPE_MOVIE elif self._session_type == 'track': return MEDIA_TYPE_MUSIC diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index 15b16eec11b..a46e781de59 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -9,7 +9,7 @@ import logging import voluptuous as vol from homeassistant.components.media_player import ( - MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PLAY_MEDIA, + MEDIA_TYPE_MOVIE, SUPPORT_NEXT_TRACK, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( @@ -146,6 +146,11 @@ class RokuDevice(MediaPlayerDevice): """Flag media player features that are supported.""" return SUPPORT_ROKU + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return self.device_info.sernum + @property def media_content_type(self): """Content type of current playing media.""" @@ -155,7 +160,7 @@ class RokuDevice(MediaPlayerDevice): return None elif self.current_app.name == "Roku": return None - return MEDIA_TYPE_VIDEO + return MEDIA_TYPE_MOVIE @property def media_image_url(self): diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index beaea8a8ad0..0a6c413a688 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -131,8 +131,8 @@ play_media: description: The ID of the content to play. Platform dependent. example: 'https://home-assistant.io/images/cast/splash.png' media_content_type: - description: The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST - example: 'MUSIC' + description: The type of the content to play. Must be one of music, tvshow, video, episode, channel or playlist + example: 'music' select_source: description: Send the media player the command to change input source. @@ -402,3 +402,13 @@ songpal_set_sound_setting: value: description: Value to set. example: 'on' + +blackbird_set_all_zones: + description: Set all Blackbird zones to a single source. + fields: + entity_id: + description: Name of any blackbird zone. + example: 'media_player.zone_1' + source: + description: Name of source to switch to. + example: 'Source 1' diff --git a/homeassistant/components/media_player/songpal.py b/homeassistant/components/media_player/songpal.py index b1dc7df3319..5d0962775f0 100644 --- a/homeassistant/components/media_player/songpal.py +++ b/homeassistant/components/media_player/songpal.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-songpal==0.0.6'] +REQUIREMENTS = ['python-songpal==0.0.7'] SUPPORT_SONGPAL = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE | \ @@ -101,7 +101,7 @@ class SongpalDevice(MediaPlayerDevice): import songpal self._name = name self.endpoint = endpoint - self.dev = songpal.Protocol(self.endpoint) + self.dev = songpal.Device(self.endpoint) self._sysinfo = None self._state = False @@ -151,10 +151,10 @@ class SongpalDevice(MediaPlayerDevice): return if len(volumes) > 1: - _LOGGER.warning("Got %s volume controls, using the first one", - volumes) + _LOGGER.debug("Got %s volume controls, using the first one", + volumes) - volume = volumes.pop() + volume = volumes[0] _LOGGER.debug("Current volume: %s", volume) self._volume_max = volume.maxVolume diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 9ea33b4c396..0f536e1edfb 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -54,7 +54,6 @@ DATA_SONOS = 'sonos' SOURCE_LINEIN = 'Line-in' SOURCE_TV = 'TV' -CONF_ADVERTISE_ADDR = 'advertise_addr' CONF_INTERFACE_ADDR = 'interface_addr' # Service call validation schemas @@ -68,12 +67,11 @@ ATTR_WITH_GROUP = 'with_group' ATTR_NIGHT_SOUND = 'night_sound' ATTR_SPEECH_ENHANCE = 'speech_enhance' -ATTR_IS_COORDINATOR = 'is_coordinator' +ATTR_SONOS_GROUP = 'sonos_group' UPNP_ERRORS_TO_IGNORE = ['701', '711'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_ADVERTISE_ADDR): cv.string, vol.Optional(CONF_INTERFACE_ADDR): cv.string, vol.Optional(CONF_HOSTS): vol.All(cv.ensure_list, [cv.string]), }) @@ -141,10 +139,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if DATA_SONOS not in hass.data: hass.data[DATA_SONOS] = SonosData() - advertise_addr = config.get(CONF_ADVERTISE_ADDR, None) - if advertise_addr: - soco.config.EVENT_ADVERTISE_IP = advertise_addr - + players = [] if discovery_info: player = soco.SoCo(discovery_info.get('host')) @@ -152,25 +147,24 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if player.uid in hass.data[DATA_SONOS].uids: return - if player.is_visible: - hass.data[DATA_SONOS].uids.add(player.uid) - add_devices([SonosDevice(player)]) + # If invisible, such as a stereo slave + if not player.is_visible: + return + + players.append(player) else: - players = None - hosts = config.get(CONF_HOSTS, None) + hosts = config.get(CONF_HOSTS) if hosts: # Support retro compatibility with comma separated list of hosts # from config hosts = hosts[0] if len(hosts) == 1 else hosts hosts = hosts.split(',') if isinstance(hosts, str) else hosts - players = [] for host in hosts: try: players.append(soco.SoCo(socket.gethostbyname(host))) except OSError: _LOGGER.warning("Failed to initialize '%s'", host) - - if not players: + else: players = soco.discover( interface_addr=config.get(CONF_INTERFACE_ADDR)) @@ -178,9 +172,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.warning("No Sonos speakers found") return - hass.data[DATA_SONOS].uids.update([p.uid for p in players]) - add_devices([SonosDevice(p) for p in players]) - _LOGGER.debug("Added %s Sonos speakers", len(players)) + hass.data[DATA_SONOS].uids.update(p.uid for p in players) + add_devices(SonosDevice(p) for p in players) + _LOGGER.debug("Added %s Sonos speakers", len(players)) def service_handle(service): """Handle for services.""" @@ -194,13 +188,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): master = [device for device in hass.data[DATA_SONOS].devices if device.entity_id == service.data[ATTR_MASTER]] if master: - master[0].join(devices) + with hass.data[DATA_SONOS].topology_lock: + master[0].join(devices) + return + + if service.service == SERVICE_UNJOIN: + with hass.data[DATA_SONOS].topology_lock: + for device in devices: + device.unjoin() return for device in devices: - if service.service == SERVICE_UNJOIN: - device.unjoin() - elif service.service == SERVICE_SNAPSHOT: + if service.service == SERVICE_SNAPSHOT: device.snapshot(service.data[ATTR_WITH_GROUP]) elif service.service == SERVICE_RESTORE: device.restore(service.data[ATTR_WITH_GROUP]) @@ -209,9 +208,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): elif service.service == SERVICE_CLEAR_TIMER: device.clear_sleep_timer() elif service.service == SERVICE_UPDATE_ALARM: - device.update_alarm(**service.data) + device.set_alarm(**service.data) elif service.service == SERVICE_SET_OPTION: - device.update_option(**service.data) + device.set_option(**service.data) device.schedule_update_ha_state(True) @@ -322,7 +321,7 @@ def _is_radio_uri(uri): """Return whether the URI is a radio stream.""" radio_schemes = ( 'x-rincon-mp3radio:', 'x-sonosapi-stream:', 'x-sonosapi-radio:', - 'hls-radio:') + 'x-sonosapi-hls:', 'hls-radio:') return uri.startswith(radio_schemes) @@ -331,15 +330,17 @@ class SonosDevice(MediaPlayerDevice): def __init__(self, player): """Initialize the Sonos device.""" + self._receives_events = False self._volume_increment = 5 self._unique_id = player.uid self._player = player self._model = None self._player_volume = None - self._player_volume_muted = None + self._player_muted = None self._play_mode = None self._name = None self._coordinator = None + self._sonos_group = None self._status = None self._media_duration = None self._media_position = None @@ -421,130 +422,23 @@ class SonosDevice(MediaPlayerDevice): speaker_info = self.soco.get_speaker_info(True) self._name = speaker_info['zone_name'] self._model = speaker_info['model_name'] - self._player_volume = self.soco.volume - self._player_volume_muted = self.soco.mute - self._play_mode = self.soco.play_mode - self._night_sound = self.soco.night_mode - self._speech_enhance = self.soco.dialog_mode - self._favorites = self.soco.music_library.get_sonos_favorites() - - def _subscribe_to_player_events(self): - """Add event subscriptions.""" - player = self.soco - - # New player available, build the current group topology - for device in self.hass.data[DATA_SONOS].devices: - device.process_zonegrouptopology_event(None) - - queue = _ProcessSonosEventQueue(self.process_avtransport_event) - player.avTransport.subscribe(auto_renew=True, event_queue=queue) - - queue = _ProcessSonosEventQueue(self.process_rendering_event) - player.renderingControl.subscribe(auto_renew=True, event_queue=queue) - - queue = _ProcessSonosEventQueue(self.process_zonegrouptopology_event) - player.zoneGroupTopology.subscribe(auto_renew=True, event_queue=queue) - - def update(self): - """Retrieve latest state.""" - available = self._check_available() - if self._available != available: - self._available = available - if available: - self._set_basic_information() - self._subscribe_to_player_events() - else: - self._player_volume = None - self._player_volume_muted = None - self._status = 'OFF' - self._coordinator = None - self._media_duration = None - self._media_position = None - self._media_position_updated_at = None - self._media_image_url = None - self._media_artist = None - self._media_album_name = None - self._media_title = None - self._source_name = None - - def process_avtransport_event(self, event): - """Process a track change event coming from a coordinator.""" - transport_info = self.soco.get_current_transport_info() - new_status = transport_info.get('current_transport_state') - - # Ignore transitions, we should get the target state soon - if new_status == 'TRANSITIONING': - return - self._play_mode = self.soco.play_mode - if self.soco.is_playing_tv: - self._refresh_linein(SOURCE_TV) - elif self.soco.is_playing_line_in: - self._refresh_linein(SOURCE_LINEIN) - else: - track_info = self.soco.get_current_track_info() + self.update_volume() - if _is_radio_uri(track_info['uri']): - self._refresh_radio(event.variables, track_info) - else: - update_position = (new_status != self._status) - self._refresh_music(update_position, track_info) - - self._status = new_status - - self.schedule_update_ha_state() - - # Also update slaves - for entity in self.hass.data[DATA_SONOS].devices: - coordinator = entity.coordinator - if coordinator and coordinator.unique_id == self.unique_id: - entity.schedule_update_ha_state() - - def process_rendering_event(self, event): - """Process a volume change event coming from a player.""" - variables = event.variables - - if 'volume' in variables: - self._player_volume = int(variables['volume']['Master']) - - if 'mute' in variables: - self._player_volume_muted = (variables['mute']['Master'] == '1') - - if 'night_mode' in variables: - self._night_sound = (variables['night_mode'] == '1') - - if 'dialog_level' in variables: - self._speech_enhance = (variables['dialog_level'] == '1') - - self.schedule_update_ha_state() - - def process_zonegrouptopology_event(self, event): - """Process a zone group topology event coming from a player.""" - if event and not hasattr(event, 'zone_player_uui_ds_in_group'): - return - - with self.hass.data[DATA_SONOS].topology_lock: - group = event and event.zone_player_uui_ds_in_group - if group: - # New group information is pushed - coordinator_uid, *slave_uids = group.split(',') - else: - # Use SoCo cache for existing topology - coordinator_uid = self.soco.group.coordinator.uid - slave_uids = [p.uid for p in self.soco.group.members - if p.uid != coordinator_uid] - - if self.unique_id == coordinator_uid: - self._coordinator = None - self.schedule_update_ha_state() - - for slave_uid in slave_uids: - slave = _get_entity_from_soco_uid(self.hass, slave_uid) - if slave: - # pylint: disable=protected-access - slave._coordinator = self - slave.schedule_update_ha_state() + self._favorites = [] + # SoCo 0.14 raises a generic Exception on invalid xml in favorites. + # Filter those out now so our list is safe to use. + # pylint: disable=broad-except + try: + for fav in self.soco.music_library.get_sonos_favorites(): + try: + if fav.reference.get_uri(): + self._favorites.append(fav) + except Exception: + _LOGGER.debug("Ignoring invalid favorite '%s'", fav.title) + except Exception: + _LOGGER.debug("Ignoring invalid favorite list") def _radio_artwork(self, url): """Return the private URL with artwork for a radio stream.""" @@ -559,7 +453,88 @@ class SonosDevice(MediaPlayerDevice): ) return url - def _refresh_linein(self, source): + def _subscribe_to_player_events(self): + """Add event subscriptions.""" + self._receives_events = False + + # New player available, build the current group topology + for device in self.hass.data[DATA_SONOS].devices: + device.update_groups() + + player = self.soco + + queue = _ProcessSonosEventQueue(self.update_media) + player.avTransport.subscribe(auto_renew=True, event_queue=queue) + + queue = _ProcessSonosEventQueue(self.update_volume) + player.renderingControl.subscribe(auto_renew=True, event_queue=queue) + + queue = _ProcessSonosEventQueue(self.update_groups) + player.zoneGroupTopology.subscribe(auto_renew=True, event_queue=queue) + + def update(self): + """Retrieve latest state.""" + available = self._check_available() + if self._available != available: + self._available = available + if available: + self._set_basic_information() + self._subscribe_to_player_events() + else: + self._player_volume = None + self._player_muted = None + self._status = 'OFF' + self._coordinator = None + self._media_duration = None + self._media_position = None + self._media_position_updated_at = None + self._media_image_url = None + self._media_artist = None + self._media_album_name = None + self._media_title = None + self._source_name = None + elif available and not self._receives_events: + self.update_groups() + self.update_volume() + if self.is_coordinator: + self.update_media() + + def update_media(self, event=None): + """Update information about currently playing media.""" + transport_info = self.soco.get_current_transport_info() + new_status = transport_info.get('current_transport_state') + + # Ignore transitions, we should get the target state soon + if new_status == 'TRANSITIONING': + return + + self._play_mode = self.soco.play_mode + + if self.soco.is_playing_tv: + self.update_media_linein(SOURCE_TV) + elif self.soco.is_playing_line_in: + self.update_media_linein(SOURCE_LINEIN) + else: + track_info = self.soco.get_current_track_info() + + if _is_radio_uri(track_info['uri']): + variables = event and event.variables + self.update_media_radio(variables, track_info) + else: + update_position = (new_status != self._status) + self.update_media_music(update_position, track_info) + + self._status = new_status + + self.schedule_update_ha_state() + + # Also update slaves + for entity in self.hass.data[DATA_SONOS].devices: + coordinator = entity.coordinator + if coordinator and coordinator.unique_id == self.unique_id: + entity.schedule_update_ha_state() + + def update_media_linein(self, source): """Update state when playing from line-in/tv.""" self._media_duration = None self._media_position = None @@ -573,7 +548,7 @@ class SonosDevice(MediaPlayerDevice): self._source_name = source - def _refresh_radio(self, variables, track_info): + def update_media_radio(self, variables, track_info): """Update state when streaming radio.""" self._media_duration = None self._media_position = None @@ -594,7 +569,7 @@ class SonosDevice(MediaPlayerDevice): artist=self._media_artist, title=self._media_title ) - else: + elif variables: # "On Now" field in the sonos pc app current_track_metadata = variables.get('current_track_meta_data') if current_track_metadata: @@ -634,7 +609,7 @@ class SonosDevice(MediaPlayerDevice): if fav.reference.get_uri() == media_info['CurrentURI']: self._source_name = fav.title - def _refresh_music(self, update_media_position, track_info): + def update_media_music(self, update_media_position, track_info): """Update state when playing music tracks.""" self._media_duration = _timespan_secs(track_info.get('duration')) @@ -673,6 +648,72 @@ class SonosDevice(MediaPlayerDevice): self._source_name = None + def update_volume(self, event=None): + """Update information about currently volume settings.""" + if event: + variables = event.variables + + if 'volume' in variables: + self._player_volume = int(variables['volume']['Master']) + + if 'mute' in variables: + self._player_muted = (variables['mute']['Master'] == '1') + + if 'night_mode' in variables: + self._night_sound = (variables['night_mode'] == '1') + + if 'dialog_level' in variables: + self._speech_enhance = (variables['dialog_level'] == '1') + + self.schedule_update_ha_state() + else: + self._player_volume = self.soco.volume + self._player_muted = self.soco.mute + self._night_sound = self.soco.night_mode + self._speech_enhance = self.soco.dialog_mode + + def update_groups(self, event=None): + """Process a zone group topology event coming from a player.""" + if event: + self._receives_events = True + + if not hasattr(event, 'zone_player_uui_ds_in_group'): + return + + with self.hass.data[DATA_SONOS].topology_lock: + group = event and event.zone_player_uui_ds_in_group + if group: + # New group information is pushed + coordinator_uid, *slave_uids = group.split(',') + elif self.soco.group: + # Use SoCo cache for existing topology + coordinator_uid = self.soco.group.coordinator.uid + slave_uids = [p.uid for p in self.soco.group.members + if p.uid != coordinator_uid] + else: + # Not yet in the cache, this can happen when a speaker boots + coordinator_uid = self.unique_id + slave_uids = [] + + if self.unique_id == coordinator_uid: + sonos_group = [] + for uid in (coordinator_uid, *slave_uids): + entity = _get_entity_from_soco_uid(self.hass, uid) + if entity: + sonos_group.append(entity.entity_id) + + self._coordinator = None + self._sonos_group = sonos_group + self.schedule_update_ha_state() + + for slave_uid in slave_uids: + slave = _get_entity_from_soco_uid(self.hass, slave_uid) + if slave: + # pylint: disable=protected-access + slave._coordinator = self + slave._sonos_group = sonos_group + slave.schedule_update_ha_state() + @property def volume_level(self): """Volume level of the media player (0..1).""" @@ -681,7 +722,7 @@ class SonosDevice(MediaPlayerDevice): @property def is_volume_muted(self): """Return true if volume is muted.""" - return self._player_volume_muted + return self._player_muted @property @soco_coordinator @@ -789,7 +830,9 @@ class SonosDevice(MediaPlayerDevice): src = fav.pop() uri = src.reference.get_uri() if _is_radio_uri(uri): - self.soco.play_uri(uri, title=source) + # SoCo 0.14 fails to XML escape the title parameter + from xml.sax.saxutils import escape + self.soco.play_uri(uri, title=escape(source)) else: self.soco.clear_queue() self.soco.add_to_queue(src.reference) @@ -883,15 +926,19 @@ class SonosDevice(MediaPlayerDevice): def join(self, slaves): """Form a group with other players.""" if self._coordinator: - self.soco.unjoin() + self.unjoin() for slave in slaves: - slave.soco.join(self.soco) + if slave.unique_id != self.unique_id: + slave.soco.join(self.soco) + # pylint: disable=protected-access + slave._coordinator = self @soco_error() def unjoin(self): """Unjoin the player from a group.""" self.soco.unjoin() + self._coordinator = None @soco_error() def snapshot(self, with_group=True): @@ -973,7 +1020,7 @@ class SonosDevice(MediaPlayerDevice): @soco_error() @soco_coordinator - def update_alarm(self, **data): + def set_alarm(self, **data): """Set the alarm clock on the player.""" from soco import alarms alarm = None @@ -996,7 +1043,7 @@ class SonosDevice(MediaPlayerDevice): alarm.save() @soco_error() - def update_option(self, **data): + def set_option(self, **data): """Modify playback options.""" if ATTR_NIGHT_SOUND in data and self._night_sound is not None: self.soco.night_mode = data[ATTR_NIGHT_SOUND] @@ -1007,7 +1054,7 @@ class SonosDevice(MediaPlayerDevice): @property def device_state_attributes(self): """Return device specific state attributes.""" - attributes = {ATTR_IS_COORDINATOR: self.is_coordinator} + attributes = {ATTR_SONOS_GROUP: self._sonos_group} if self._night_sound is not None: attributes[ATTR_NIGHT_SOUND] = self._night_sound diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index 734285d918a..963258f1861 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -194,7 +194,7 @@ class SpotifyMediaPlayer(MediaPlayerDevice): self._title = item.get('name') self._artist = ', '.join([artist.get('name') for artist in item.get('artists')]) - self._uri = current.get('uri') + self._uri = item.get('uri') images = item.get('album').get('images') self._image_url = images[0].get('url') if images else None # Playing state diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index 86b4087ca81..371ad890364 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -266,6 +266,8 @@ class SqueezeBoxDevice(MediaPlayerDevice): if response is False: return + last_media_position = self.media_position + self._status = {} try: @@ -278,7 +280,11 @@ class SqueezeBoxDevice(MediaPlayerDevice): pass self._status.update(response) - self._last_update = utcnow() + + if self.media_position != last_media_position: + _LOGGER.debug('Media position updated for %s: %s', + self, self.media_position) + self._last_update = utcnow() @property def volume_level(self): diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index 27a0714527d..03f847ae40c 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -4,7 +4,6 @@ Combination of multiple media players into one for a universal controller. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.universal/ """ -import asyncio import logging # pylint: disable=import-error from copy import copy @@ -31,7 +30,8 @@ from homeassistant.const import ( SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, - SERVICE_SHUFFLE_SET, STATE_IDLE, STATE_OFF, STATE_ON, SERVICE_MEDIA_STOP) + SERVICE_SHUFFLE_SET, STATE_IDLE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + SERVICE_MEDIA_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_call_from_config @@ -46,7 +46,7 @@ CONF_SERVICE_DATA = 'service_data' ATTR_DATA = 'data' CONF_STATE = 'state' -OFF_STATES = [STATE_IDLE, STATE_OFF] +OFF_STATES = [STATE_IDLE, STATE_OFF, STATE_UNAVAILABLE] REQUIREMENTS = [] _LOGGER = logging.getLogger(__name__) @@ -63,8 +63,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }, extra=vol.REMOVE_EXTRA) -@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 universal media players.""" player = UniversalMediaPlayer( hass, @@ -99,8 +99,7 @@ class UniversalMediaPlayer(MediaPlayerDevice): if state_template is not None: self._state_template.hass = hass - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe to children and template state changes. This method must be run in the event loop and returns a coroutine. @@ -144,15 +143,14 @@ class UniversalMediaPlayer(MediaPlayerDevice): active_child = self._child_state return active_child.attributes.get(attr_name) if active_child else None - @asyncio.coroutine - def _async_call_service(self, service_name, service_data=None, - allow_override=False): + async def _async_call_service(self, service_name, service_data=None, + allow_override=False): """Call either a specified or active child's service.""" if service_data is None: service_data = {} if allow_override and service_name in self._cmds: - yield from async_call_from_config( + await async_call_from_config( self.hass, self._cmds[service_name], variables=service_data, blocking=True, validate_config=False) @@ -165,7 +163,7 @@ class UniversalMediaPlayer(MediaPlayerDevice): service_data[ATTR_ENTITY_ID] = active_child.entity_id - yield from self.hass.services.async_call( + await self.hass.services.async_call( DOMAIN, service_name, service_data, blocking=True) @property @@ -506,8 +504,7 @@ class UniversalMediaPlayer(MediaPlayerDevice): return self._async_call_service( SERVICE_SHUFFLE_SET, data, allow_override=True) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update state in HA.""" for child_name in self._children: child_state = self.hass.states.get(child_name) diff --git a/homeassistant/components/media_player/vizio.py b/homeassistant/components/media_player/vizio.py index 64d1f642e6e..381482a4839 100644 --- a/homeassistant/components/media_player/vizio.py +++ b/homeassistant/components/media_player/vizio.py @@ -19,7 +19,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv import homeassistant.util as util -REQUIREMENTS = ['pyvizio==0.0.2'] +REQUIREMENTS = ['pyvizio==0.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/volumio.py b/homeassistant/components/media_player/volumio.py index 0a940c0aa9d..11ab1615617 100644 --- a/homeassistant/components/media_player/volumio.py +++ b/homeassistant/components/media_player/volumio.py @@ -8,6 +8,7 @@ Volumio rest API: https://volumio.github.io/docs/API/REST_API.html """ from datetime import timedelta import logging +import socket import asyncio import aiohttp @@ -31,6 +32,8 @@ DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'Volumio' DEFAULT_PORT = 3000 +DATA_VOLUMIO = 'volumio' + TIMEOUT = 10 SUPPORT_VOLUMIO = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ @@ -50,11 +53,29 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Volumio platform.""" - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - name = config.get(CONF_NAME) + if DATA_VOLUMIO not in hass.data: + hass.data[DATA_VOLUMIO] = dict() - async_add_devices([Volumio(name, host, port, hass)]) + # This is a manual configuration? + if discovery_info is None: + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + else: + name = "{} ({})".format(DEFAULT_NAME, discovery_info.get('hostname')) + host = discovery_info.get('host') + port = discovery_info.get('port') + + # Only add a device once, so discovered devices do not override manual + # config. + ip_addr = socket.gethostbyname(host) + if ip_addr in hass.data[DATA_VOLUMIO]: + return + + entity = Volumio(name, host, port, hass) + + hass.data[DATA_VOLUMIO][ip_addr] = entity + async_add_devices([entity]) class Volumio(MediaPlayerDevice): diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index acd1ffad6eb..c3426e45404 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -35,6 +35,7 @@ CONF_SOURCES = 'sources' CONF_ON_ACTION = 'turn_on_action' DEFAULT_NAME = 'LG webOS Smart TV' +LIVETV_APP_ID = 'com.webos.app.livetv' WEBOSTV_CONFIG_FILE = 'webostv.conf' @@ -343,6 +344,39 @@ class LgWebOSDevice(MediaPlayerDevice): self._current_source = source_dict['label'] self._client.set_input(source_dict['id']) + def play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" + _LOGGER.debug( + "Call play media type <%s>, Id <%s>", media_type, media_id) + + if media_type == MEDIA_TYPE_CHANNEL: + _LOGGER.debug("Searching channel...") + partial_match_channel_id = None + perfect_match_channel_id = None + + for channel in self._client.get_channels(): + if media_id == channel['channelNumber']: + perfect_match_channel_id = channel['channelId'] + continue + elif media_id.lower() == channel['channelName'].lower(): + perfect_match_channel_id = channel['channelId'] + continue + elif media_id.lower() in channel['channelName'].lower(): + partial_match_channel_id = channel['channelId'] + + if perfect_match_channel_id is not None: + _LOGGER.info( + "Switching to channel <%s> with perfect match", + perfect_match_channel_id) + self._client.set_channel(perfect_match_channel_id) + elif partial_match_channel_id is not None: + _LOGGER.info( + "Switching to channel <%s> with partial match", + partial_match_channel_id) + self._client.set_channel(partial_match_channel_id) + + return + def media_play(self): """Send play command.""" self._playing = True @@ -357,8 +391,16 @@ class LgWebOSDevice(MediaPlayerDevice): def media_next_track(self): """Send next track command.""" - self._client.fast_forward() + current_input = self._client.get_input() + if current_input == LIVETV_APP_ID: + self._client.channel_up() + else: + self._client.fast_forward() def media_previous_track(self): """Send the previous track command.""" - self._client.rewind() + current_input = self._client.get_input() + if current_input == LIVETV_APP_ID: + self._client.channel_down() + else: + self._client.rewind() diff --git a/homeassistant/components/media_player/yamaha.py b/homeassistant/components/media_player/yamaha.py index 5b8ac2ad236..bb7942a2545 100644 --- a/homeassistant/components/media_player/yamaha.py +++ b/homeassistant/components/media_player/yamaha.py @@ -222,7 +222,7 @@ class YamahaDevice(MediaPlayerDevice): @property def zone_id(self): - """Return an zone_id to ensure 1 media player per zone.""" + """Return a zone_id to ensure 1 media player per zone.""" return '{0}:{1}'.format(self.receiver.ctrl_url, self._zone) @property diff --git a/homeassistant/components/mercedesme.py b/homeassistant/components/mercedesme.py deleted file mode 100644 index b809e46ec64..00000000000 --- a/homeassistant/components/mercedesme.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -Support for MercedesME System. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/mercedesme/ -""" -import asyncio -import logging -from datetime import timedelta - -import voluptuous as vol -import homeassistant.helpers.config_validation as cv - -from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, LENGTH_KILOMETERS) -from homeassistant.helpers import discovery -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, dispatcher_send) -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_time_interval - -REQUIREMENTS = ['mercedesmejsonpy==0.1.2'] - -_LOGGER = logging.getLogger(__name__) - -BINARY_SENSORS = { - 'doorsClosed': ['Doors closed'], - 'windowsClosed': ['Windows closed'], - 'locked': ['Doors locked'], - 'tireWarningLight': ['Tire Warning'] -} - -SENSORS = { - 'fuelLevelPercent': ['Fuel Level', '%'], - 'fuelRangeKm': ['Fuel Range', LENGTH_KILOMETERS], - 'latestTrip': ['Latest Trip', None], - 'odometerKm': ['Odometer', LENGTH_KILOMETERS], - 'serviceIntervalDays': ['Next Service', 'days'] -} - -DATA_MME = 'mercedesme' -DOMAIN = 'mercedesme' - -FEATURE_NOT_AVAILABLE = "The feature %s is not available for your car %s" - -NOTIFICATION_ID = 'mercedesme_integration_notification' -NOTIFICATION_TITLE = 'Mercedes me integration setup' - -SIGNAL_UPDATE_MERCEDESME = "mercedesme_update" - - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=30): - vol.All(cv.positive_int, vol.Clamp(min=10)) - }) -}, extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up MercedesMe System.""" - from mercedesmejsonpy.controller import Controller - from mercedesmejsonpy import Exceptions - - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - scan_interval = conf.get(CONF_SCAN_INTERVAL) - - try: - mercedesme_api = Controller(username, password, scan_interval) - if not mercedesme_api.is_valid_session: - raise Exceptions.MercedesMeException(500) - hass.data[DATA_MME] = MercedesMeHub(mercedesme_api) - except Exceptions.MercedesMeException as ex: - if ex.code == 401: - hass.components.persistent_notification.create( - "Error:
Please check username and password." - "You will need to restart Home Assistant after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - else: - hass.components.persistent_notification.create( - "Error:
Can't communicate with Mercedes me API.
" - "Error code: {} Reason: {}" - "You will need to restart Home Assistant after fixing." - "".format(ex.code, ex.message), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - - _LOGGER.error("Unable to communicate with Mercedes me API: %s", - ex.message) - return False - - discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) - discovery.load_platform(hass, 'device_tracker', DOMAIN, {}, config) - discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) - - def hub_refresh(event_time): - """Call Mercedes me API to refresh information.""" - _LOGGER.info("Updating Mercedes me component.") - hass.data[DATA_MME].data.update() - dispatcher_send(hass, SIGNAL_UPDATE_MERCEDESME) - - track_time_interval( - hass, - hub_refresh, - timedelta(seconds=scan_interval)) - - return True - - -class MercedesMeHub(object): - """Representation of a base MercedesMe device.""" - - def __init__(self, data): - """Initialize the entity.""" - self.data = data - - -class MercedesMeEntity(Entity): - """Entity class for MercedesMe devices.""" - - def __init__(self, data, internal_name, sensor_name, vin, unit): - """Initialize the MercedesMe entity.""" - self._car = None - self._data = data - self._state = False - self._name = sensor_name - self._internal_name = internal_name - self._unit = unit - self._vin = vin - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @asyncio.coroutine - def async_added_to_hass(self): - """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_MERCEDESME, self._update_callback) - - def _update_callback(self): - """Callback update method.""" - # If the method is made a callback this should be changed - # to the async version. Check core.callback - self.schedule_update_ha_state(True) - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit diff --git a/homeassistant/components/microsoft_face.py b/homeassistant/components/microsoft_face.py index 5a0bf2af1c4..847f4131f43 100644 --- a/homeassistant/components/microsoft_face.py +++ b/homeassistant/components/microsoft_face.py @@ -1,5 +1,5 @@ """ -Support for microsoft face recognition. +Support for Microsoft face recognition. For more details about this component, please refer to the documentation at https://home-assistant.io/components/microsoft_face/ @@ -13,38 +13,34 @@ from aiohttp.hdrs import CONTENT_TYPE import async_timeout import voluptuous as vol -from homeassistant.const import CONF_API_KEY, CONF_TIMEOUT +from homeassistant.const import CONF_API_KEY, CONF_TIMEOUT, ATTR_NAME from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.loader import get_component from homeassistant.util import slugify _LOGGER = logging.getLogger(__name__) -DOMAIN = 'microsoft_face' -DEPENDENCIES = ['camera'] - -FACE_API_URL = "api.cognitive.microsoft.com/face/v1.0/{0}" - -DATA_MICROSOFT_FACE = 'microsoft_face' +ATTR_CAMERA_ENTITY = 'camera_entity' +ATTR_GROUP = 'group' +ATTR_PERSON = 'person' CONF_AZURE_REGION = 'azure_region' +DATA_MICROSOFT_FACE = 'microsoft_face' +DEFAULT_TIMEOUT = 10 +DEPENDENCIES = ['camera'] +DOMAIN = 'microsoft_face' + +FACE_API_URL = "api.cognitive.microsoft.com/face/v1.0/{0}" + SERVICE_CREATE_GROUP = 'create_group' -SERVICE_DELETE_GROUP = 'delete_group' -SERVICE_TRAIN_GROUP = 'train_group' SERVICE_CREATE_PERSON = 'create_person' +SERVICE_DELETE_GROUP = 'delete_group' SERVICE_DELETE_PERSON = 'delete_person' SERVICE_FACE_PERSON = 'face_person' - -ATTR_GROUP = 'group' -ATTR_PERSON = 'person' -ATTR_CAMERA_ENTITY = 'camera_entity' -ATTR_NAME = 'name' - -DEFAULT_TIMEOUT = 10 +SERVICE_TRAIN_GROUP = 'train_group' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -112,7 +108,7 @@ def face_person(hass, group, person, camera_entity): @asyncio.coroutine def async_setup(hass, config): - """Set up microsoft face.""" + """Set up Microsoft Face.""" entities = {} face = MicrosoftFace( hass, @@ -231,7 +227,7 @@ def async_setup(hass, config): p_id = face.store[g_id].get(service.data[ATTR_PERSON]) camera_entity = service.data[ATTR_CAMERA_ENTITY] - camera = get_component('camera') + camera = hass.components.camera try: image = yield from camera.async_get_image(hass, camera_entity) @@ -240,7 +236,7 @@ def async_setup(hass, config): 'post', "persongroups/{0}/persons/{1}/persistedFaces".format( g_id, p_id), - image, + image.content, binary=True ) except HomeAssistantError as err: diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index b81a4fc16a7..55d99a0817e 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -90,22 +90,52 @@ ATTR_RETAIN = CONF_RETAIN MAX_RECONNECT_WAIT = 300 # seconds -def valid_subscribe_topic(value: Any, invalid_chars='\0') -> str: - """Validate that we can subscribe using this MQTT topic.""" +def valid_topic(value: Any) -> str: + """Validate that this is a valid topic name/filter.""" value = cv.string(value) - if all(c not in value for c in invalid_chars): - return vol.Length(min=1, max=65535)(value) - raise vol.Invalid('Invalid MQTT topic name') + try: + raw_value = value.encode('utf-8') + except UnicodeError: + raise vol.Invalid("MQTT topic name/filter must be valid UTF-8 string.") + if not raw_value: + raise vol.Invalid("MQTT topic name/filter must not be empty.") + if len(raw_value) > 65535: + raise vol.Invalid("MQTT topic name/filter must not be longer than " + "65535 encoded bytes.") + if '\0' in value: + raise vol.Invalid("MQTT topic name/filter must not contain null " + "character.") + return value + + +def valid_subscribe_topic(value: Any) -> str: + """Validate that we can subscribe using this MQTT topic.""" + value = valid_topic(value) + for i in (i for i, c in enumerate(value) if c == '+'): + if (i > 0 and value[i - 1] != '/') or \ + (i < len(value) - 1 and value[i + 1] != '/'): + raise vol.Invalid("Single-level wildcard must occupy an entire " + "level of the filter") + + index = value.find('#') + if index != -1: + if index != len(value) - 1: + # If there are multiple wildcards, this will also trigger + raise vol.Invalid("Multi-level wildcard must be the last " + "character in the topic filter.") + if len(value) > 1 and value[index - 1] != '/': + raise vol.Invalid("Multi-level wildcard must be after a topic " + "level separator.") + + return value def valid_publish_topic(value: Any) -> str: """Validate that we can publish using this MQTT topic.""" - return valid_subscribe_topic(value, invalid_chars='#+\0') - - -def valid_discovery_topic(value: Any) -> str: - """Validate a discovery topic.""" - return valid_subscribe_topic(value, invalid_chars='#+\0/') + value = valid_topic(value) + if '+' in value or '#' in value: + raise vol.Invalid("Wildcards can not be used in topic names") + return value _VALID_QOS_SCHEMA = vol.All(vol.Coerce(int), vol.In([0, 1, 2])) @@ -143,8 +173,10 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_WILL_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, vol.Optional(CONF_BIRTH_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean, + # discovery_prefix must be a valid publish topic because if no + # state topic is specified, it will be created with the given prefix. vol.Optional(CONF_DISCOVERY_PREFIX, - default=DEFAULT_DISCOVERY_PREFIX): valid_discovery_topic, + default=DEFAULT_DISCOVERY_PREFIX): valid_publish_topic, }), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index b6f6a1c5a92..d5a3b4a2efb 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -4,7 +4,6 @@ Support for MQTT discovery. For more details about this component, please refer to the documentation at https://home-assistant.io/components/mqtt/#discovery """ -import asyncio import json import logging import re @@ -21,13 +20,16 @@ TOPIC_MATCHER = re.compile( r'(?:(?P[a-zA-Z0-9_-]+)/)?(?P[a-zA-Z0-9_-]+)/config') SUPPORTED_COMPONENTS = [ - 'binary_sensor', 'cover', 'fan', 'light', 'sensor', 'switch'] + 'binary_sensor', 'camera', 'cover', 'fan', + 'light', 'sensor', 'switch', 'lock'] ALLOWED_PLATFORMS = { 'binary_sensor': ['mqtt'], + 'camera': ['mqtt'], 'cover': ['mqtt'], 'fan': ['mqtt'], 'light': ['mqtt', 'mqtt_json', 'mqtt_template'], + 'lock': ['mqtt'], 'sensor': ['mqtt'], 'switch': ['mqtt'], } @@ -35,19 +37,16 @@ ALLOWED_PLATFORMS = { ALREADY_DISCOVERED = 'mqtt_discovered_components' -@asyncio.coroutine -def async_start(hass, discovery_topic, hass_config): +async def async_start(hass, discovery_topic, hass_config): """Initialize of MQTT Discovery.""" - # pylint: disable=unused-variable - @asyncio.coroutine - def async_device_message_received(topic, payload, qos): + async def async_device_message_received(topic, payload, qos): """Process the received message.""" match = TOPIC_MATCHER.match(topic) if not match: return - prefix_topic, component, node_id, object_id = match.groups() + _prefix_topic, component, node_id, object_id = match.groups() try: payload = json.loads(payload) @@ -88,10 +87,10 @@ def async_start(hass, discovery_topic, hass_config): _LOGGER.info("Found new component: %s %s", component, discovery_id) - yield from async_load_platform( + await async_load_platform( hass, component, platform, payload, hass_config) - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( hass, discovery_topic + '/#', async_device_message_received, 0) return True diff --git a/homeassistant/components/mqtt/server.py b/homeassistant/components/mqtt/server.py index db251ab4180..8a012928792 100644 --- a/homeassistant/components/mqtt/server.py +++ b/homeassistant/components/mqtt/server.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_STOP import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['hbmqtt==0.9.1'] +REQUIREMENTS = ['hbmqtt==0.9.2'] DEPENDENCIES = ['http'] # None allows custom config to be created through generate_config diff --git a/homeassistant/components/mqtt_eventstream.py b/homeassistant/components/mqtt_eventstream.py index 6f6cb312f2b..aa670578172 100644 --- a/homeassistant/components/mqtt_eventstream.py +++ b/homeassistant/components/mqtt_eventstream.py @@ -10,7 +10,6 @@ import json import voluptuous as vol from homeassistant.core import callback -import homeassistant.loader as loader from homeassistant.components.mqtt import ( valid_publish_topic, valid_subscribe_topic) from homeassistant.const import ( @@ -42,7 +41,7 @@ CONFIG_SCHEMA = vol.Schema({ @asyncio.coroutine def async_setup(hass, config): """Set up the MQTT eventstream component.""" - mqtt = loader.get_component('mqtt') + mqtt = hass.components.mqtt conf = config.get(DOMAIN, {}) pub_topic = conf.get(CONF_PUBLISH_TOPIC) sub_topic = conf.get(CONF_SUBSCRIBE_TOPIC) @@ -82,7 +81,7 @@ def async_setup(hass, config): event_info = {'event_type': event.event_type, 'event_data': event.data} msg = json.dumps(event_info, cls=JSONEncoder) - mqtt.async_publish(hass, pub_topic, msg) + mqtt.async_publish(pub_topic, msg) # Only listen for local events if you are going to publish them. if pub_topic: @@ -115,7 +114,7 @@ def async_setup(hass, config): # Only subscribe if you specified a topic. if sub_topic: - yield from mqtt.async_subscribe(hass, sub_topic, _event_receiver) + yield from mqtt.async_subscribe(sub_topic, _event_receiver) hass.states.async_set('{domain}.initialized'.format(domain=DOMAIN), True) return True diff --git a/homeassistant/components/mqtt_statestream.py b/homeassistant/components/mqtt_statestream.py index 4427870c294..205a638c574 100644 --- a/homeassistant/components/mqtt_statestream.py +++ b/homeassistant/components/mqtt_statestream.py @@ -88,10 +88,9 @@ def async_setup(hass, config): if publish_attributes: for key, val in new_state.attributes.items(): - if val: - encoded_val = json.dumps(val, cls=JSONEncoder) - hass.components.mqtt.async_publish(mybase + key, - encoded_val, 1, True) + encoded_val = json.dumps(val, cls=JSONEncoder) + hass.components.mqtt.async_publish(mybase + key, + encoded_val, 1, True) async_track_state_change(hass, MATCH_ALL, _state_publisher) return True diff --git a/homeassistant/components/mychevy.py b/homeassistant/components/mychevy.py index 678cdf10c56..3531c6b4919 100644 --- a/homeassistant/components/mychevy.py +++ b/homeassistant/components/mychevy.py @@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery from homeassistant.util import Throttle -REQUIREMENTS = ["mychevy==0.1.1"] +REQUIREMENTS = ["mychevy==0.4.0"] DOMAIN = 'mychevy' UPDATE_TOPIC = DOMAIN @@ -73,9 +73,6 @@ def setup(hass, base_config): hass.data[DOMAIN] = MyChevyHub(mc.MyChevy(email, password), hass) hass.data[DOMAIN].start() - discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) - discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) - return True @@ -98,8 +95,9 @@ class MyChevyHub(threading.Thread): super().__init__() self._client = client self.hass = hass - self.car = None + self.cars = [] self.status = None + self.ready = False @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -109,7 +107,22 @@ class MyChevyHub(threading.Thread): (like 2 to 3 minutes long time) """ - self.car = self._client.data() + self._client.login() + self._client.get_cars() + self.cars = self._client.cars + if self.ready is not True: + discovery.load_platform(self.hass, 'sensor', DOMAIN, {}, {}) + discovery.load_platform(self.hass, 'binary_sensor', DOMAIN, {}, {}) + self.ready = True + self.cars = self._client.update_cars() + + def get_car(self, vid): + """Compatibility to work with one car.""" + if self.cars: + for car in self.cars: + if car.vid == vid: + return car + return None def run(self): """Thread run loop.""" diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index 37e257e5eb9..1e7e252bd9d 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -12,22 +12,23 @@ import socket import sys from timeit import default_timer as timer +import async_timeout import voluptuous as vol from homeassistant.components.mqtt import ( valid_publish_topic, valid_subscribe_topic) from homeassistant.const import ( - ATTR_BATTERY_LEVEL, CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON) + ATTR_BATTERY_LEVEL, CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP, + STATE_OFF, STATE_ON) +from homeassistant.core import callback from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, dispatcher_send) + async_dispatcher_connect, async_dispatcher_send) from homeassistant.helpers.entity import Entity -from homeassistant.loader import get_component -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component -REQUIREMENTS = ['pymysensors==0.11.1'] +REQUIREMENTS = ['pymysensors==0.14.0'] _LOGGER = logging.getLogger(__name__) @@ -57,9 +58,11 @@ DEFAULT_TCP_PORT = 5003 DEFAULT_VERSION = '1.4' DOMAIN = 'mysensors' +GATEWAY_READY_TIMEOUT = 15.0 MQTT_COMPONENT = 'mqtt' MYSENSORS_GATEWAYS = 'mysensors_gateways' MYSENSORS_PLATFORM_DEVICES = 'mysensors_devices_{}' +MYSENSORS_GATEWAY_READY = 'mysensors_gateway_ready_{}' PLATFORM = 'platform' SCHEMA = 'schema' SIGNAL_CALLBACK = 'mysensors_callback_{}_{}_{}_{}' @@ -281,67 +284,62 @@ MYSENSORS_CONST_SCHEMA = { } -def setup(hass, config): +async def async_setup(hass, config): """Set up the MySensors component.""" import mysensors.mysensors as mysensors version = config[DOMAIN].get(CONF_VERSION) persistence = config[DOMAIN].get(CONF_PERSISTENCE) - def setup_gateway(device, persistence_file, baud_rate, tcp_port, in_prefix, - out_prefix): + async def setup_gateway( + device, persistence_file, baud_rate, tcp_port, in_prefix, + out_prefix): """Return gateway after setup of the gateway.""" if device == MQTT_COMPONENT: - if not setup_component(hass, MQTT_COMPONENT, config): - return - mqtt = get_component(MQTT_COMPONENT) + if not await async_setup_component(hass, MQTT_COMPONENT, config): + return None + mqtt = hass.components.mqtt retain = config[DOMAIN].get(CONF_RETAIN) def pub_callback(topic, payload, qos, retain): """Call MQTT publish function.""" - mqtt.publish(hass, topic, payload, qos, retain) + mqtt.async_publish(topic, payload, qos, retain) - def sub_callback(topic, callback, qos): + def sub_callback(topic, sub_cb, qos): """Call MQTT subscribe function.""" - mqtt.subscribe(hass, topic, callback, qos) - gateway = mysensors.MQTTGateway( - pub_callback, sub_callback, + @callback + def internal_callback(*args): + """Call callback.""" + sub_cb(*args) + + hass.async_add_job( + mqtt.async_subscribe(topic, internal_callback, qos)) + + gateway = mysensors.AsyncMQTTGateway( + pub_callback, sub_callback, in_prefix=in_prefix, + out_prefix=out_prefix, retain=retain, loop=hass.loop, event_callback=None, persistence=persistence, persistence_file=persistence_file, - protocol_version=version, in_prefix=in_prefix, - out_prefix=out_prefix, retain=retain) + protocol_version=version) else: try: - is_serial_port(device) - gateway = mysensors.SerialGateway( - device, event_callback=None, persistence=persistence, + await hass.async_add_job(is_serial_port, device) + gateway = mysensors.AsyncSerialGateway( + device, baud=baud_rate, loop=hass.loop, + event_callback=None, persistence=persistence, persistence_file=persistence_file, - protocol_version=version, baud=baud_rate) + protocol_version=version) except vol.Invalid: - try: - socket.getaddrinfo(device, None) - # valid ip address - gateway = mysensors.TCPGateway( - device, event_callback=None, persistence=persistence, - persistence_file=persistence_file, - protocol_version=version, port=tcp_port) - except OSError: - # invalid ip address - return + gateway = mysensors.AsyncTCPGateway( + device, port=tcp_port, loop=hass.loop, event_callback=None, + persistence=persistence, persistence_file=persistence_file, + protocol_version=version) gateway.metric = hass.config.units.is_metric gateway.optimistic = config[DOMAIN].get(CONF_OPTIMISTIC) gateway.device = device gateway.event_callback = gw_callback_factory(hass) - - def gw_start(event): - """Trigger to start of the gateway and any persistence.""" - if persistence: - discover_persistent_devices(hass, gateway) - gateway.start() - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, - lambda event: gateway.stop()) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, gw_start) + if persistence: + await gateway.start_persistence() return gateway @@ -358,12 +356,12 @@ def setup(hass, config): tcp_port = gway.get(CONF_TCP_PORT) in_prefix = gway.get(CONF_TOPIC_IN_PREFIX, '') out_prefix = gway.get(CONF_TOPIC_OUT_PREFIX, '') - ready_gateway = setup_gateway( + gateway = await setup_gateway( device, persistence_file, baud_rate, tcp_port, in_prefix, out_prefix) - if ready_gateway is not None: - ready_gateway.nodes_config = gway.get(CONF_NODES) - gateways[id(ready_gateway)] = ready_gateway + if gateway is not None: + gateway.nodes_config = gway.get(CONF_NODES) + gateways[id(gateway)] = gateway if not gateways: _LOGGER.error( @@ -372,9 +370,65 @@ def setup(hass, config): hass.data[MYSENSORS_GATEWAYS] = gateways + hass.async_add_job(finish_setup(hass, gateways)) + return True +async def finish_setup(hass, gateways): + """Load any persistent devices and platforms and start gateway.""" + discover_tasks = [] + start_tasks = [] + for gateway in gateways.values(): + discover_tasks.append(discover_persistent_devices(hass, gateway)) + start_tasks.append(gw_start(hass, gateway)) + if discover_tasks: + # Make sure all devices and platforms are loaded before gateway start. + await asyncio.wait(discover_tasks, loop=hass.loop) + if start_tasks: + await asyncio.wait(start_tasks, loop=hass.loop) + + +async def gw_start(hass, gateway): + """Start the gateway.""" + @callback + def gw_stop(event): + """Trigger to stop the gateway.""" + hass.async_add_job(gateway.stop()) + + await gateway.start() + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw_stop) + if gateway.device == 'mqtt': + # Gatways connected via mqtt doesn't send gateway ready message. + return + gateway_ready = asyncio.Future() + gateway_ready_key = MYSENSORS_GATEWAY_READY.format(id(gateway)) + hass.data[gateway_ready_key] = gateway_ready + + try: + with async_timeout.timeout(GATEWAY_READY_TIMEOUT, loop=hass.loop): + await gateway_ready + except asyncio.TimeoutError: + _LOGGER.warning( + "Gateway %s not ready after %s secs so continuing with setup", + gateway.device, GATEWAY_READY_TIMEOUT) + finally: + hass.data.pop(gateway_ready_key, None) + + +@callback +def set_gateway_ready(hass, msg): + """Set asyncio future result if gateway is ready.""" + if (msg.type != msg.gateway.const.MessageType.internal or + msg.sub_type != msg.gateway.const.Internal.I_GATEWAY_READY): + return + gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format( + id(msg.gateway))) + if gateway_ready is None or gateway_ready.cancelled(): + return + gateway_ready.set_result(True) + + def validate_child(gateway, node_id, child): """Validate that a child has the correct values according to schema. @@ -432,14 +486,18 @@ def validate_child(gateway, node_id, child): return validated +@callback def discover_mysensors_platform(hass, platform, new_devices): """Discover a MySensors platform.""" - discovery.load_platform( - hass, platform, DOMAIN, {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}) + task = hass.async_add_job(discovery.async_load_platform( + hass, platform, DOMAIN, + {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN})) + return task -def discover_persistent_devices(hass, gateway): +async def discover_persistent_devices(hass, gateway): """Discover platforms for devices loaded via persistence file.""" + tasks = [] new_devices = defaultdict(list) for node_id in gateway.sensors: node = gateway.sensors[node_id] @@ -448,7 +506,9 @@ def discover_persistent_devices(hass, gateway): for platform, dev_ids in validated.items(): new_devices[platform].extend(dev_ids) for platform, dev_ids in new_devices.items(): - discover_mysensors_platform(hass, platform, dev_ids) + tasks.append(discover_mysensors_platform(hass, platform, dev_ids)) + if tasks: + await asyncio.wait(tasks, loop=hass.loop) def get_mysensors_devices(hass, domain): @@ -460,14 +520,18 @@ def get_mysensors_devices(hass, domain): def gw_callback_factory(hass): """Return a new callback for the gateway.""" + @callback def mysensors_callback(msg): """Handle messages from a MySensors gateway.""" start = timer() _LOGGER.debug( "Node update: node %s child %s", msg.node_id, msg.child_id) - child = msg.gateway.sensors[msg.node_id].children.get(msg.child_id) - if child is None: + set_gateway_ready(hass, msg) + + try: + child = msg.gateway.sensors[msg.node_id].children[msg.child_id] + except KeyError: _LOGGER.debug("Not a child update for node %s", msg.node_id) return @@ -490,7 +554,7 @@ def gw_callback_factory(hass): # Only one signal per device is needed. # A device can have multiple platforms, ie multiple schemas. # FOR LATER: Add timer to not signal if another update comes in. - dispatcher_send(hass, signal) + async_dispatcher_send(hass, signal) end = timer() if end - start > 0.1: _LOGGER.debug( @@ -518,11 +582,12 @@ def get_mysensors_gateway(hass, gateway_id): return gateways.get(gateway_id) +@callback def setup_mysensors_platform( hass, domain, discovery_info, device_class, device_args=None, - add_devices=None): + async_add_devices=None): """Set up a MySensors platform.""" - # Only act if called via MySensors by discovery event. + # Only act if called via mysensors by discovery event. # Otherwise gateway is not setup. if not discovery_info: return @@ -545,15 +610,14 @@ def setup_mysensors_platform( device_class_copy = device_class[s_type] name = get_mysensors_name(gateway, node_id, child_id) - # python 3.4 cannot unpack inside tuple, but combining tuples works - args_copy = device_args + ( - gateway, node_id, child_id, name, value_type) + args_copy = (*device_args, gateway, node_id, child_id, name, + value_type) devices[dev_id] = device_class_copy(*args_copy) new_devices.append(devices[dev_id]) if new_devices: _LOGGER.info("Adding new devices: %s", new_devices) - if add_devices is not None: - add_devices(new_devices, True) + if async_add_devices is not None: + async_add_devices(new_devices, True) return new_devices @@ -596,7 +660,7 @@ class MySensorsDevice(object): return attr - def update(self): + async def async_update(self): """Update the controller with the latest value from a sensor.""" node = self.gateway.sensors[self.node_id] child = node.children[self.child_id] @@ -628,14 +692,14 @@ class MySensorsEntity(MySensorsDevice, Entity): """Return true if entity is available.""" return self.value_type in self._values - def _async_update_callback(self): + @callback + def async_update_callback(self): """Update the entity.""" self.async_schedule_update_ha_state(True) - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Register update callback.""" dev_id = id(self.gateway), self.node_id, self.child_id, self.value_type async_dispatcher_connect( self.hass, SIGNAL_CALLBACK.format(*dev_id), - self._async_update_callback) + self.async_update_callback) diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index 37028decf71..16a0b80d1fd 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -4,18 +4,22 @@ Support for Nest devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/nest/ """ +from concurrent.futures import ThreadPoolExecutor import logging import socket import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import discovery from homeassistant.const import ( CONF_STRUCTURE, CONF_FILENAME, CONF_BINARY_SENSORS, CONF_SENSORS, - CONF_MONITORED_CONDITIONS) + CONF_MONITORED_CONDITIONS, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import discovery, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send, \ + async_dispatcher_connect +from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-nest==3.1.0'] +REQUIREMENTS = ['python-nest==4.0.1'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -24,6 +28,8 @@ DOMAIN = 'nest' DATA_NEST = 'nest' +SIGNAL_NEST_UPDATE = 'nest_update' + NEST_CONFIG_FILE = 'nest.conf' CONF_CLIENT_ID = 'client_id' CONF_CLIENT_SECRET = 'client_secret' @@ -51,23 +57,44 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -def request_configuration(nest, hass, config): +async def async_nest_update_event_broker(hass, nest): + """ + Dispatch SIGNAL_NEST_UPDATE to devices when nest stream API received data. + + nest.update_event.wait will block the thread in most of time, + so specific an executor to save default thread pool. + """ + _LOGGER.debug("listening nest.update_event") + with ThreadPoolExecutor(max_workers=1) as executor: + while True: + await hass.loop.run_in_executor(executor, nest.update_event.wait) + if hass.is_running: + nest.update_event.clear() + _LOGGER.debug("dispatching nest data update") + async_dispatcher_send(hass, SIGNAL_NEST_UPDATE) + else: + return + + +async def async_request_configuration(nest, hass, config): """Request configuration steps from the user.""" configurator = hass.components.configurator if 'nest' in _CONFIGURING: _LOGGER.debug("configurator failed") - configurator.notify_errors( + configurator.async_notify_errors( _CONFIGURING['nest'], "Failed to configure, please try again.") return - def nest_configuration_callback(data): + async def async_nest_config_callback(data): """Run when the configuration callback is called.""" _LOGGER.debug("configurator callback") pin = data.get('pin') - setup_nest(hass, nest, config, pin=pin) + if await async_setup_nest(hass, nest, config, pin=pin): + # start nest update event listener as we missed startup hook + hass.async_add_job(async_nest_update_event_broker, hass, nest) - _CONFIGURING['nest'] = configurator.request_config( - "Nest", nest_configuration_callback, + _CONFIGURING['nest'] = configurator.async_request_config( + "Nest", async_nest_config_callback, description=('To configure Nest, click Request Authorization below, ' 'log into your Nest account, ' 'and then enter the resulting PIN'), @@ -78,60 +105,47 @@ def request_configuration(nest, hass, config): ) -def setup_nest(hass, nest, config, pin=None): +async def async_setup_nest(hass, nest, config, pin=None): """Set up the Nest devices.""" + from nest.nest import AuthorizationError, APIError if pin is not None: _LOGGER.debug("pin acquired, requesting access token") - nest.request_token(pin) + error_message = None + try: + nest.request_token(pin) + except AuthorizationError as auth_error: + error_message = "Nest authorization failed: {}".format(auth_error) + except APIError as api_error: + error_message = "Failed to call Nest API: {}".format(api_error) + + if error_message is not None: + _LOGGER.warning(error_message) + hass.components.configurator.async_notify_errors( + _CONFIGURING['nest'], error_message) + return False if nest.access_token is None: _LOGGER.debug("no access_token, requesting configuration") - request_configuration(nest, hass, config) - return + await async_request_configuration(nest, hass, config) + return False if 'nest' in _CONFIGURING: _LOGGER.debug("configuration done") configurator = hass.components.configurator - configurator.request_done(_CONFIGURING.pop('nest')) + configurator.async_request_done(_CONFIGURING.pop('nest')) _LOGGER.debug("proceeding with setup") conf = config[DOMAIN] hass.data[DATA_NEST] = NestDevice(hass, conf, nest) - _LOGGER.debug("proceeding with discovery") - discovery.load_platform(hass, 'climate', DOMAIN, {}, config) - discovery.load_platform(hass, 'camera', DOMAIN, {}, config) - - sensor_config = conf.get(CONF_SENSORS, {}) - discovery.load_platform(hass, 'sensor', DOMAIN, sensor_config, config) - - binary_sensor_config = conf.get(CONF_BINARY_SENSORS, {}) - discovery.load_platform(hass, 'binary_sensor', DOMAIN, - binary_sensor_config, config) - - _LOGGER.debug("setup done") - - return True - - -def setup(hass, config): - """Set up the Nest thermostat component.""" - import nest - - if 'nest' in _CONFIGURING: - return - - conf = config[DOMAIN] - client_id = conf[CONF_CLIENT_ID] - client_secret = conf[CONF_CLIENT_SECRET] - filename = config.get(CONF_FILENAME, NEST_CONFIG_FILE) - - access_token_cache_file = hass.config.path(filename) - - nest = nest.Nest( - access_token_cache_file=access_token_cache_file, - client_id=client_id, client_secret=client_secret) - setup_nest(hass, nest, config) + for component, discovered in [ + ('climate', {}), + ('camera', {}), + ('sensor', conf.get(CONF_SENSORS, {})), + ('binary_sensor', conf.get(CONF_BINARY_SENSORS, {}))]: + _LOGGER.debug("proceeding with discovery -- %s", component) + hass.async_add_job(discovery.async_load_platform, + hass, component, DOMAIN, discovered, config) def set_mode(service): """Set the home/away mode for a Nest structure.""" @@ -148,9 +162,47 @@ def setup(hass, config): _LOGGER.error("Invalid structure %s", service.data[ATTR_STRUCTURE]) - hass.services.register( + hass.services.async_register( DOMAIN, 'set_mode', set_mode, schema=AWAY_SCHEMA) + def start_up(event): + """Start Nest update event listener.""" + hass.async_add_job(async_nest_update_event_broker, hass, nest) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_up) + + def shut_down(event): + """Stop Nest update event listener.""" + if nest: + nest.update_event.set() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shut_down) + + _LOGGER.debug("async_setup_nest is done") + + return True + + +async def async_setup(hass, config): + """Set up Nest components.""" + from nest import Nest + + if 'nest' in _CONFIGURING: + return + + conf = config[DOMAIN] + client_id = conf[CONF_CLIENT_ID] + client_secret = conf[CONF_CLIENT_SECRET] + filename = config.get(CONF_FILENAME, NEST_CONFIG_FILE) + + access_token_cache_file = hass.config.path(filename) + + nest = Nest( + access_token_cache_file=access_token_cache_file, + client_id=client_id, client_secret=client_secret) + + await async_setup_nest(hass, nest, config) + return True @@ -168,6 +220,19 @@ class NestDevice(object): self.local_structure = conf[CONF_STRUCTURE] _LOGGER.debug("Structures to include: %s", self.local_structure) + def structures(self): + """Generate a list of structures.""" + try: + for structure in self.nest.structures: + if structure.name in self.local_structure: + yield structure + else: + _LOGGER.debug("Ignoring structure %s, not in %s", + structure.name, self.local_structure) + except socket.error: + _LOGGER.error( + "Connection error logging into the nest web service.") + def thermostats(self): """Generate a list of thermostats and their location.""" try: @@ -188,10 +253,10 @@ class NestDevice(object): for structure in self.nest.structures: if structure.name in self.local_structure: for device in structure.smoke_co_alarms: - yield(structure, device) + yield (structure, device) else: - _LOGGER.info("Ignoring structure %s, not in %s", - structure.name, self.local_structure) + _LOGGER.debug("Ignoring structure %s, not in %s", + structure.name, self.local_structure) except socket.error: _LOGGER.error( "Connection error logging into the nest web service.") @@ -202,10 +267,61 @@ class NestDevice(object): for structure in self.nest.structures: if structure.name in self.local_structure: for device in structure.cameras: - yield(structure, device) + yield (structure, device) else: - _LOGGER.info("Ignoring structure %s, not in %s", - structure.name, self.local_structure) + _LOGGER.debug("Ignoring structure %s, not in %s", + structure.name, self.local_structure) except socket.error: _LOGGER.error( "Connection error logging into the nest web service.") + + +class NestSensorDevice(Entity): + """Representation of a Nest sensor.""" + + def __init__(self, structure, device, variable): + """Initialize the sensor.""" + self.structure = structure + self.variable = variable + + if device is not None: + # device specific + self.device = device + self._name = "{} {}".format(self.device.name_long, + self.variable.replace('_', ' ')) + else: + # structure only + self.device = structure + self._name = "{} {}".format(self.structure.name, + self.variable.replace('_', ' ')) + + self._state = None + self._unit = None + + @property + def name(self): + """Return the name of the nest, if any.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def should_poll(self): + """Do not need poll thanks using Nest streaming API.""" + return False + + def update(self): + """Do not use NestSensorDevice directly.""" + raise NotImplementedError + + async def async_added_to_hass(self): + """Register update signal handler.""" + async def async_update_state(): + """Update sensor state.""" + await self.async_update_ha_state(True) + + async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, + async_update_state) diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index dcbd1ce1317..9cca81e1485 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -12,8 +12,8 @@ import voluptuous as vol from homeassistant.helpers.event import track_state_change from homeassistant.config import load_yaml_config_file from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_DATA, BaseNotificationService, DOMAIN) -from homeassistant.const import CONF_NAME, CONF_PLATFORM + ATTR_TARGET, ATTR_DATA, BaseNotificationService, DOMAIN, PLATFORM_SCHEMA) +from homeassistant.const import CONF_NAME, CONF_PLATFORM, ATTR_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers import template as template_helper @@ -27,9 +27,8 @@ DEVICE_TRACKER_DOMAIN = 'device_tracker' SERVICE_REGISTER = 'apns_register' ATTR_PUSH_ID = 'push_id' -ATTR_NAME = 'name' -PLATFORM_SCHEMA = vol.Schema({ +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): 'apns', vol.Required(CONF_NAME): cv.string, vol.Required(CONF_CERTFILE): cv.isfile, @@ -66,7 +65,7 @@ class ApnsDevice(object): """ def __init__(self, push_id, name, tracking_device_id=None, disabled=False): - """Initialize Apns Device.""" + """Initialize APNS Device.""" self.device_push_id = push_id self.device_name = name self.tracking_id = tracking_device_id @@ -104,7 +103,7 @@ class ApnsDevice(object): @property def disabled(self): - """Return the .""" + """Return the state of the service.""" return self.device_disabled def disable(self): diff --git a/homeassistant/components/notify/clicksend.py b/homeassistant/components/notify/clicksend.py index 2b2cb4e7f22..c028da2c579 100644 --- a/homeassistant/components/notify/clicksend.py +++ b/homeassistant/components/notify/clicksend.py @@ -37,7 +37,8 @@ PLATFORM_SCHEMA = vol.Schema( vol.All(PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_RECIPIENT): cv.string, + vol.Required(CONF_RECIPIENT, default=[]): + vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_SENDER): cv.string, }), validate_sender)) @@ -59,21 +60,19 @@ class ClicksendNotificationService(BaseNotificationService): """Initialize the service.""" self.username = config.get(CONF_USERNAME) self.api_key = config.get(CONF_API_KEY) - self.recipient = config.get(CONF_RECIPIENT) + self.recipients = config.get(CONF_RECIPIENT) self.sender = config.get(CONF_SENDER, CONF_RECIPIENT) def send_message(self, message="", **kwargs): """Send a message to a user.""" - data = ({ - 'messages': [ - { - 'source': 'hass.notify', - 'from': self.sender, - 'to': self.recipient, - 'body': message, - } - ] - }) + data = {"messages": []} + for recipient in self.recipients: + data["messages"].append({ + 'source': 'hass.notify', + 'from': self.sender, + 'to': recipient, + 'body': message, + }) api_url = "{}/sms/send".format(BASE_API_URL) diff --git a/homeassistant/components/notify/facebook.py b/homeassistant/components/notify/facebook.py index 791440fdb5b..b73f845ea17 100644 --- a/homeassistant/components/notify/facebook.py +++ b/homeassistant/components/notify/facebook.py @@ -4,6 +4,7 @@ Facebook platform for notify component. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.facebook/ """ +import json import logging from aiohttp.hdrs import CONTENT_TYPE @@ -19,6 +20,8 @@ _LOGGER = logging.getLogger(__name__) CONF_PAGE_ACCESS_TOKEN = 'page_access_token' BASE_URL = 'https://graph.facebook.com/v2.6/me/messages' +CREATE_BROADCAST_URL = 'https://graph.facebook.com/v2.11/me/message_creatives' +SEND_BROADCAST_URL = 'https://graph.facebook.com/v2.11/me/broadcast_messages' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PAGE_ACCESS_TOKEN): cv.string, @@ -55,27 +58,60 @@ class FacebookNotificationService(BaseNotificationService): _LOGGER.error("At least 1 target is required") return - for target in targets: - # If the target starts with a "+", we suppose it's a phone number, - # otherwise it's a user id. - if target.startswith('+'): - recipient = {"phone_number": target} - else: - recipient = {"id": target} + # broadcast message + if targets[0].lower() == 'broadcast': + broadcast_create_body = {"messages": [body_message]} + _LOGGER.debug("Broadcast body %s : ", broadcast_create_body) - body = { - "recipient": recipient, - "message": body_message + resp = requests.post(CREATE_BROADCAST_URL, + data=json.dumps(broadcast_create_body), + params=payload, + headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, + timeout=10) + _LOGGER.debug("FB Messager broadcast id %s : ", resp.json()) + + # at this point we get broadcast id + broadcast_body = { + "message_creative_id": resp.json().get('message_creative_id'), + "notification_type": "REGULAR", } - import json - resp = requests.post(BASE_URL, data=json.dumps(body), + + resp = requests.post(SEND_BROADCAST_URL, + data=json.dumps(broadcast_body), params=payload, headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, timeout=10) if resp.status_code != 200: - obj = resp.json() - error_message = obj['error']['message'] - error_code = obj['error']['code'] - _LOGGER.error( - "Error %s : %s (Code %s)", resp.status_code, error_message, - error_code) + log_error(resp) + + # non-broadcast message + else: + for target in targets: + # If the target starts with a "+", it's a phone number, + # otherwise it's a user id. + if target.startswith('+'): + recipient = {"phone_number": target} + else: + recipient = {"id": target} + + body = { + "recipient": recipient, + "message": body_message + } + resp = requests.post(BASE_URL, data=json.dumps(body), + params=payload, + headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, + timeout=10) + if resp.status_code != 200: + log_error(resp) + + +def log_error(response): + """Log error message.""" + obj = response.json() + error_message = obj['error']['message'] + error_code = obj['error']['code'] + + _LOGGER.error( + "Error %s : %s (Code %s)", response.status_code, error_message, + error_code) diff --git a/homeassistant/components/notify/flock.py b/homeassistant/components/notify/flock.py new file mode 100644 index 00000000000..d26f629809f --- /dev/null +++ b/homeassistant/components/notify/flock.py @@ -0,0 +1,61 @@ +""" +Flock platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.flock/ +""" +import asyncio +import logging + +import async_timeout +import voluptuous as vol + +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = 'https://api.flock.com/hooks/sendMessage/' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, +}) + + +async def get_service(hass, config, discovery_info=None): + """Get the Flock notification service.""" + access_token = config.get(CONF_ACCESS_TOKEN) + url = '{}{}'.format(_RESOURCE, access_token) + session = async_get_clientsession(hass) + + return FlockNotificationService(url, session, hass.loop) + + +class FlockNotificationService(BaseNotificationService): + """Implement the notification service for Flock.""" + + def __init__(self, url, session, loop): + """Initialize the Flock notification service.""" + self._loop = loop + self._url = url + self._session = session + + async def async_send_message(self, message, **kwargs): + """Send the message to the user.""" + payload = {'text': message} + + _LOGGER.debug("Attempting to call Flock at %s", self._url) + + try: + with async_timeout.timeout(10, loop=self._loop): + response = await self._session.post(self._url, json=payload) + result = await response.json() + + if response.status != 200 or 'error' in result: + _LOGGER.error( + "Flock service returned HTTP status %d, response %s", + response.status, result) + except asyncio.TimeoutError: + _LOGGER.error("Timeout accessing Flock at %s", self._url) diff --git a/homeassistant/components/notify/lametric.py b/homeassistant/components/notify/lametric.py index f4c9c391408..f6c3e152b0a 100644 --- a/homeassistant/components/notify/lametric.py +++ b/homeassistant/components/notify/lametric.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/notify.lametric/ """ import logging +from requests.exceptions import ConnectionError as RequestsConnectionError import voluptuous as vol from homeassistant.components.notify import ( @@ -22,11 +23,16 @@ _LOGGER = logging.getLogger(__name__) CONF_LIFETIME = "lifetime" CONF_CYCLES = "cycles" +CONF_PRIORITY = "priority" + +AVAILABLE_PRIORITIES = ["info", "warning", "critical"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ICON, default="i555"): cv.string, vol.Optional(CONF_LIFETIME, default=10): cv.positive_int, vol.Optional(CONF_CYCLES, default=1): cv.positive_int, + vol.Optional(CONF_PRIORITY, default="warning"): + vol.In(AVAILABLE_PRIORITIES) }) @@ -37,18 +43,21 @@ def get_service(hass, config, discovery_info=None): return LaMetricNotificationService(hlmn, config[CONF_ICON], config[CONF_LIFETIME] * 1000, - config[CONF_CYCLES]) + config[CONF_CYCLES], + config[CONF_PRIORITY]) class LaMetricNotificationService(BaseNotificationService): """Implement the notification service for LaMetric.""" - def __init__(self, hasslametricmanager, icon, lifetime, cycles): + def __init__(self, hasslametricmanager, icon, lifetime, cycles, priority): """Initialize the service.""" self.hasslametricmanager = hasslametricmanager self._icon = icon self._lifetime = lifetime self._cycles = cycles + self._priority = priority + self._devices = [] # pylint: disable=broad-except def send_message(self, message="", **kwargs): @@ -62,6 +71,7 @@ class LaMetricNotificationService(BaseNotificationService): icon = self._icon cycles = self._cycles sound = None + priority = self._priority # Additional data? if data is not None: @@ -76,6 +86,14 @@ class LaMetricNotificationService(BaseNotificationService): except AssertionError: _LOGGER.error("Sound ID %s unknown, ignoring", data["sound"]) + if "cycles" in data: + cycles = data['cycles'] + if "priority" in data: + if data['priority'] in AVAILABLE_PRIORITIES: + priority = data['priority'] + else: + _LOGGER.warning("Priority %s invalid, using default %s", + data['priority'], priority) text_frame = SimpleFrame(icon, message) _LOGGER.debug("Icon/Message/Cycles/Lifetime: %s, %s, %d, %d", @@ -86,16 +104,20 @@ class LaMetricNotificationService(BaseNotificationService): model = Model(frames=frames, cycles=cycles, sound=sound) lmn = self.hasslametricmanager.manager try: - devices = lmn.get_devices() + self._devices = lmn.get_devices() except TokenExpiredError: _LOGGER.debug("Token expired, fetching new token") lmn.get_token() - devices = lmn.get_devices() - for dev in devices: + self._devices = lmn.get_devices() + except RequestsConnectionError: + _LOGGER.warning("Problem connecting to LaMetric, " + "using cached devices instead") + for dev in self._devices: if targets is None or dev["name"] in targets: try: lmn.set_device(dev) - lmn.send_notification(model, lifetime=self._lifetime) + lmn.send_notification(model, lifetime=self._lifetime, + priority=priority) _LOGGER.debug("Sent notification to LaMetric %s", dev["name"]) except OSError: diff --git a/homeassistant/components/notify/mastodon.py b/homeassistant/components/notify/mastodon.py new file mode 100644 index 00000000000..3ba95407fec --- /dev/null +++ b/homeassistant/components/notify/mastodon.py @@ -0,0 +1,70 @@ +""" +Mastodon platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.mastodon/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_ACCESS_TOKEN +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['Mastodon.py==1.2.2'] + +_LOGGER = logging.getLogger(__name__) + +CONF_BASE_URL = 'base_url' +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' + +DEFAULT_URL = 'https://mastodon.social' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_BASE_URL, default=DEFAULT_URL): cv.string, +}) + + +def get_service(hass, config, discovery_info=None): + """Get the Mastodon notification service.""" + from mastodon import Mastodon + from mastodon.Mastodon import MastodonUnauthorizedError + + client_id = config.get(CONF_CLIENT_ID) + client_secret = config.get(CONF_CLIENT_SECRET) + access_token = config.get(CONF_ACCESS_TOKEN) + base_url = config.get(CONF_BASE_URL) + + try: + mastodon = Mastodon( + client_id=client_id, client_secret=client_secret, + access_token=access_token, api_base_url=base_url) + mastodon.account_verify_credentials() + except MastodonUnauthorizedError: + _LOGGER.warning("Authentication failed") + return None + + return MastodonNotificationService(mastodon) + + +class MastodonNotificationService(BaseNotificationService): + """Implement the notification service for Mastodon.""" + + def __init__(self, api): + """Initialize the service.""" + self._api = api + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + from mastodon.Mastodon import MastodonAPIError + + try: + self._api.toot(message) + except MastodonAPIError: + _LOGGER.error("Unable to send message") diff --git a/homeassistant/components/notify/matrix.py b/homeassistant/components/notify/matrix.py index 03bc53e204c..fc29ad91dc9 100644 --- a/homeassistant/components/notify/matrix.py +++ b/homeassistant/components/notify/matrix.py @@ -5,181 +5,46 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.matrix/ """ import logging -import os -from urllib.parse import urlparse import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import (ATTR_TARGET, PLATFORM_SCHEMA, - BaseNotificationService) -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_VERIFY_SSL -from homeassistant.util.json import load_json, save_json - -REQUIREMENTS = ['matrix-client==0.0.6'] + BaseNotificationService, + ATTR_MESSAGE) _LOGGER = logging.getLogger(__name__) -SESSION_FILE = 'matrix.conf' - -CONF_HOMESERVER = 'homeserver' CONF_DEFAULT_ROOM = 'default_room' +DOMAIN = 'matrix' +DEPENDENCIES = [DOMAIN] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOMESERVER): cv.url, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_DEFAULT_ROOM): cv.string, }) def get_service(hass, config, discovery_info=None): """Get the Matrix notification service.""" - from matrix_client.client import MatrixRequestError - - try: - return MatrixNotificationService( - os.path.join(hass.config.path(), SESSION_FILE), - config.get(CONF_HOMESERVER), - config.get(CONF_DEFAULT_ROOM), - config.get(CONF_VERIFY_SSL), - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD)) - - except MatrixRequestError: - return None + return MatrixNotificationService(config.get(CONF_DEFAULT_ROOM)) class MatrixNotificationService(BaseNotificationService): """Send Notifications to a Matrix Room.""" - def __init__(self, config_file, homeserver, default_room, verify_ssl, - username, password): - """Set up the client.""" - self.session_filepath = config_file - self.auth_tokens = self.get_auth_tokens() + def __init__(self, default_room): + """Set up the notification service.""" + self._default_room = default_room - self.homeserver = homeserver - self.default_room = default_room - self.verify_tls = verify_ssl - self.username = username - self.password = password - - self.mx_id = "{user}@{homeserver}".format( - user=username, homeserver=urlparse(homeserver).netloc) - - # Login, this will raise a MatrixRequestError if login is unsuccessful - self.client = self.login() - - def get_auth_tokens(self): - """ - Read sorted authentication tokens from disk. - - Returns the auth_tokens dictionary. - """ - if not os.path.exists(self.session_filepath): - return {} - - try: - data = load_json(self.session_filepath) - - auth_tokens = {} - for mx_id, token in data.items(): - auth_tokens[mx_id] = token - - return auth_tokens - - except (OSError, IOError, PermissionError) as ex: - _LOGGER.warning( - "Loading authentication tokens from file '%s' failed: %s", - self.session_filepath, str(ex)) - return {} - - def store_auth_token(self, token): - """Store authentication token to session and persistent storage.""" - self.auth_tokens[self.mx_id] = token - - save_json(self.session_filepath, self.auth_tokens) - - def login(self): - """Login to the matrix homeserver and return the client instance.""" - from matrix_client.client import MatrixRequestError - - # Attempt to generate a valid client using either of the two possible - # login methods: - client = None - - # If we have an authentication token - if self.mx_id in self.auth_tokens: - try: - client = self.login_by_token() - _LOGGER.debug("Logged in using stored token.") - - except MatrixRequestError as ex: - _LOGGER.warning( - "Login by token failed, falling back to password. " - "login_by_token raised: (%d) %s", - ex.code, ex.content) - - # If we still don't have a client try password. - if not client: - try: - client = self.login_by_password() - _LOGGER.debug("Logged in using password.") - - except MatrixRequestError as ex: - _LOGGER.error( - "Login failed, both token and username/password invalid " - "login_by_password raised: (%d) %s", - ex.code, ex.content) - - # re-raise the error so the constructor can catch it. - raise - - return client - - def login_by_token(self): - """Login using authentication token and return the client.""" - from matrix_client.client import MatrixClient - - return MatrixClient( - base_url=self.homeserver, - token=self.auth_tokens[self.mx_id], - user_id=self.username, - valid_cert_check=self.verify_tls) - - def login_by_password(self): - """Login using password authentication and return the client.""" - from matrix_client.client import MatrixClient - - _client = MatrixClient( - base_url=self.homeserver, - valid_cert_check=self.verify_tls) - - _client.login_with_password(self.username, self.password) - - self.store_auth_token(_client.token) - - return _client - - def send_message(self, message, **kwargs): + def send_message(self, message="", **kwargs): """Send the message to the matrix server.""" - from matrix_client.client import MatrixRequestError + target_rooms = kwargs.get(ATTR_TARGET) or [self._default_room] - target_rooms = kwargs.get(ATTR_TARGET) or [self.default_room] + service_data = { + ATTR_TARGET: target_rooms, + ATTR_MESSAGE: message + } - rooms = self.client.get_rooms() - for target_room in target_rooms: - try: - if target_room in rooms: - room = rooms[target_room] - else: - room = self.client.join_room(target_room) - - _LOGGER.debug(room.send_text(message)) - - except MatrixRequestError as ex: - _LOGGER.error( - "Unable to deliver message to room '%s': (%d): %s", - target_room, ex.code, ex.content) + return self.hass.services.call( + DOMAIN, 'send_message', service_data=service_data) diff --git a/homeassistant/components/notify/mysensors.py b/homeassistant/components/notify/mysensors.py index 8ae697048f5..1374779c5f0 100644 --- a/homeassistant/components/notify/mysensors.py +++ b/homeassistant/components/notify/mysensors.py @@ -9,12 +9,12 @@ from homeassistant.components.notify import ( ATTR_TARGET, DOMAIN, BaseNotificationService) -def get_service(hass, config, discovery_info=None): +async def async_get_service(hass, config, discovery_info=None): """Get the MySensors notification service.""" new_devices = mysensors.setup_mysensors_platform( hass, DOMAIN, discovery_info, MySensorsNotificationDevice) if not new_devices: - return + return None return MySensorsNotificationService(hass) @@ -42,7 +42,7 @@ class MySensorsNotificationService(BaseNotificationService): """Initialize the service.""" self.devices = mysensors.get_mysensors_devices(hass, DOMAIN) - def send_message(self, message="", **kwargs): + async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" target_devices = kwargs.get(ATTR_TARGET) devices = [device for device in self.devices.values() diff --git a/homeassistant/components/notify/nma.py b/homeassistant/components/notify/nma.py deleted file mode 100644 index e81dc457a81..00000000000 --- a/homeassistant/components/notify/nma.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -NMA (Notify My Android) notification service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.nma/ -""" -import logging -import xml.etree.ElementTree as ET - -import requests -import voluptuous as vol - -from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import CONF_API_KEY -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) -_RESOURCE = 'https://www.notifymyandroid.com/publicapi/' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, -}) - - -def get_service(hass, config, discovery_info=None): - """Get the NMA notification service.""" - parameters = { - 'apikey': config[CONF_API_KEY], - } - response = requests.get( - '{}{}'.format(_RESOURCE, 'verify'), params=parameters, timeout=5) - tree = ET.fromstring(response.content) - - if tree[0].tag == 'error': - _LOGGER.error("Wrong API key supplied: %s", tree[0].text) - return None - - return NmaNotificationService(config[CONF_API_KEY]) - - -class NmaNotificationService(BaseNotificationService): - """Implement the notification service for NMA.""" - - def __init__(self, api_key): - """Initialize the service.""" - self._api_key = api_key - - def send_message(self, message="", **kwargs): - """Send a message to a user.""" - data = { - 'apikey': self._api_key, - 'application': 'home-assistant', - 'event': kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), - 'description': message, - 'priority': 0, - } - - response = requests.get( - '{}{}'.format(_RESOURCE, 'notify'), params=data, timeout=5) - tree = ET.fromstring(response.content) - - if tree[0].tag == 'error': - _LOGGER.exception( - "Unable to perform request. Error: %s", tree[0].text) diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py index 73618c19502..40b09dc3c72 100644 --- a/homeassistant/components/notify/rest.py +++ b/homeassistant/components/notify/rest.py @@ -12,7 +12,8 @@ import voluptuous as vol from homeassistant.components.notify import ( ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_RESOURCE, CONF_METHOD, CONF_NAME) +from homeassistant.const import (CONF_RESOURCE, CONF_METHOD, CONF_NAME, + CONF_HEADERS) import homeassistant.helpers.config_validation as cv CONF_DATA = 'data' @@ -29,6 +30,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ default=DEFAULT_MESSAGE_PARAM_NAME): cv.string, vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(['POST', 'GET', 'POST_JSON']), + vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_TARGET_PARAMETER_NAME): cv.string, vol.Optional(CONF_TITLE_PARAMETER_NAME): cv.string, @@ -43,6 +45,7 @@ def get_service(hass, config, discovery_info=None): """Get the RESTful notification service.""" resource = config.get(CONF_RESOURCE) method = config.get(CONF_METHOD) + headers = config.get(CONF_HEADERS) message_param_name = config.get(CONF_MESSAGE_PARAMETER_NAME) title_param_name = config.get(CONF_TITLE_PARAMETER_NAME) target_param_name = config.get(CONF_TARGET_PARAMETER_NAME) @@ -50,19 +53,20 @@ def get_service(hass, config, discovery_info=None): data_template = config.get(CONF_DATA_TEMPLATE) return RestNotificationService( - hass, resource, method, message_param_name, + hass, resource, method, headers, message_param_name, title_param_name, target_param_name, data, data_template) class RestNotificationService(BaseNotificationService): """Implementation of a notification service for REST.""" - def __init__(self, hass, resource, method, message_param_name, + def __init__(self, hass, resource, method, headers, message_param_name, title_param_name, target_param_name, data, data_template): """Initialize the service.""" self._resource = resource self._hass = hass self._method = method.upper() + self._headers = headers self._message_param_name = message_param_name self._title_param_name = title_param_name self._target_param_name = target_param_name @@ -99,11 +103,14 @@ class RestNotificationService(BaseNotificationService): data.update(_data_template_creator(self._data_template)) if self._method == 'POST': - response = requests.post(self._resource, data=data, timeout=10) + response = requests.post(self._resource, headers=self._headers, + data=data, timeout=10) elif self._method == 'POST_JSON': - response = requests.post(self._resource, json=data, timeout=10) + response = requests.post(self._resource, headers=self._headers, + json=data, timeout=10) else: # default GET - response = requests.get(self._resource, params=data, timeout=10) + response = requests.get(self._resource, headers=self._headers, + params=data, timeout=10) if response.status_code not in (200, 201): _LOGGER.exception( diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index 30aadfc8297..b50260e4c61 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( BaseNotificationService) from homeassistant.const import (CONF_API_KEY, CONF_USERNAME, CONF_ICON) -REQUIREMENTS = ['slacker==0.9.60'] +REQUIREMENTS = ['slacker==0.9.65'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/stride.py b/homeassistant/components/notify/stride.py new file mode 100644 index 00000000000..f31e50a5886 --- /dev/null +++ b/homeassistant/components/notify/stride.py @@ -0,0 +1,102 @@ +""" +Stride platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.stride/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.notify import ( + ATTR_TARGET, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_TOKEN, CONF_ROOM + +REQUIREMENTS = ['pystride==0.1.7'] + +_LOGGER = logging.getLogger(__name__) + +CONF_PANEL = 'panel' +CONF_CLOUDID = 'cloudid' + +DEFAULT_PANEL = None + +VALID_PANELS = {'info', 'note', 'tip', 'warning', None} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_CLOUDID): cv.string, + vol.Required(CONF_ROOM): cv.string, + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_PANEL, default=DEFAULT_PANEL): vol.In(VALID_PANELS), +}) + + +def get_service(hass, config, discovery_info=None): + """Get the Stride notification service.""" + return StrideNotificationService( + config[CONF_TOKEN], config[CONF_ROOM], config[CONF_PANEL], + config[CONF_CLOUDID]) + + +class StrideNotificationService(BaseNotificationService): + """Implement the notification service for Stride.""" + + def __init__(self, token, default_room, default_panel, cloudid): + """Initialize the service.""" + self._token = token + self._default_room = default_room + self._default_panel = default_panel + self._cloudid = cloudid + + from stride import Stride + self._stride = Stride(self._cloudid, access_token=self._token) + + def send_message(self, message="", **kwargs): + """Send a message.""" + panel = self._default_panel + + if kwargs.get(ATTR_DATA) is not None: + data = kwargs.get(ATTR_DATA) + if ((data.get(CONF_PANEL) is not None) + and (data.get(CONF_PANEL) in VALID_PANELS)): + panel = data.get(CONF_PANEL) + + message_text = { + 'type': 'paragraph', + 'content': [ + { + 'type': 'text', + 'text': message + } + ] + } + panel_text = message_text + if panel is not None: + panel_text = { + 'type': 'panel', + 'attrs': + { + 'panelType': panel + }, + 'content': + [ + message_text, + ] + } + + message_doc = { + 'body': { + 'version': 1, + 'type': 'doc', + 'content': + [ + panel_text, + ] + } + } + + targets = kwargs.get(ATTR_TARGET, [self._default_room]) + + for target in targets: + self._stride.message_room(target, message_doc) diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index db7de8e40a0..e38e7fcaa0f 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -19,7 +19,7 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME from homeassistant.helpers.event import async_track_point_in_time -REQUIREMENTS = ['TwitterAPI==2.4.10'] +REQUIREMENTS = ['TwitterAPI==2.5.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index 806acdb6d09..12ddf49fca8 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -76,8 +76,6 @@ def send_message(sender, password, recipient, use_tls, """Initialize the Jabber Bot.""" super(SendNotificationBot, self).__init__(sender, password) - logging.basicConfig(level=logging.ERROR) - self.use_tls = use_tls self.use_ipv6 = False self.add_event_handler('failed_auth', self.check_credentials) diff --git a/homeassistant/components/panel_custom.py b/homeassistant/components/panel_custom.py index 473d44f3b55..4659578ae27 100644 --- a/homeassistant/components/panel_custom.py +++ b/homeassistant/components/panel_custom.py @@ -4,7 +4,6 @@ Register a custom front end panel. For more details about this component, please refer to the documentation at https://home-assistant.io/components/panel_custom/ """ -import asyncio import logging import os @@ -21,27 +20,33 @@ CONF_SIDEBAR_ICON = 'sidebar_icon' CONF_URL_PATH = 'url_path' CONF_CONFIG = 'config' CONF_WEBCOMPONENT_PATH = 'webcomponent_path' +CONF_JS_URL = 'js_url' +CONF_EMBED_IFRAME = 'embed_iframe' +CONF_TRUST_EXTERNAL_SCRIPT = 'trust_external_script' DEFAULT_ICON = 'mdi:bookmark' +LEGACY_URL = '/api/panel_custom/{}' PANEL_DIR = 'panels' CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.ensure_list, [{ - vol.Required(CONF_COMPONENT_NAME): cv.slug, + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_COMPONENT_NAME): cv.string, vol.Optional(CONF_SIDEBAR_TITLE): cv.string, vol.Optional(CONF_SIDEBAR_ICON, default=DEFAULT_ICON): cv.icon, vol.Optional(CONF_URL_PATH): cv.string, - vol.Optional(CONF_CONFIG): cv.match_all, + vol.Optional(CONF_CONFIG): dict, vol.Optional(CONF_WEBCOMPONENT_PATH): cv.isfile, - }]) + vol.Optional(CONF_JS_URL): cv.string, + vol.Optional(CONF_EMBED_IFRAME, default=False): cv.boolean, + vol.Optional(CONF_TRUST_EXTERNAL_SCRIPT, default=False): cv.boolean, + })]) }, extra=vol.ALLOW_EXTRA) _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Initialize custom panel.""" success = False @@ -52,17 +57,39 @@ def async_setup(hass, config): if panel_path is None: panel_path = hass.config.path(PANEL_DIR, '{}.html'.format(name)) - if not os.path.isfile(panel_path): + custom_panel_config = { + 'name': name, + 'embed_iframe': panel[CONF_EMBED_IFRAME], + 'trust_external': panel[CONF_TRUST_EXTERNAL_SCRIPT], + } + + if CONF_JS_URL in panel: + custom_panel_config['js_url'] = panel[CONF_JS_URL] + + elif not await hass.async_add_job(os.path.isfile, panel_path): _LOGGER.error('Unable to find webcomponent for %s: %s', name, panel_path) continue - yield from hass.components.frontend.async_register_panel( - name, panel_path, + else: + url = LEGACY_URL.format(name) + hass.http.register_static_path(url, panel_path) + custom_panel_config['html_url'] = LEGACY_URL.format(name) + + if CONF_CONFIG in panel: + # Make copy because we're mutating it + config = dict(panel[CONF_CONFIG]) + else: + config = {} + + config['_panel_custom'] = custom_panel_config + + await hass.components.frontend.async_register_built_in_panel( + component_name='custom', sidebar_title=panel.get(CONF_SIDEBAR_TITLE), sidebar_icon=panel.get(CONF_SIDEBAR_ICON), frontend_url_path=panel.get(CONF_URL_PATH), - config=panel.get(CONF_CONFIG), + config=config ) success = True diff --git a/homeassistant/components/pilight.py b/homeassistant/components/pilight.py index 71e8232e8c2..344c750c0ec 100644 --- a/homeassistant/components/pilight.py +++ b/homeassistant/components/pilight.py @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) CONF_SEND_DELAY = 'send_delay' DEFAULT_HOST = '127.0.0.1' -DEFAULT_PORT = 5000 +DEFAULT_PORT = 5001 DEFAULT_SEND_DELAY = 0.0 DOMAIN = 'pilight' diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index f9629ca726a..96ed098567d 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -86,9 +86,16 @@ class Metrics(object): if hasattr(self, handler): getattr(self, handler)(state) + metric = self._metric( + 'state_change', + self.prometheus_client.Counter, + 'The number of state changes', + ) + metric.labels(**self._labels(state)).inc() + def _metric(self, metric, factory, documentation, labels=None): if labels is None: - labels = ['entity', 'friendly_name'] + labels = ['entity', 'friendly_name', 'domain'] try: return self._metrics[metric] @@ -100,6 +107,7 @@ class Metrics(object): def _labels(state): return { 'entity': state.entity_id, + 'domain': state.domain, 'friendly_name': state.attributes.get('friendly_name'), } @@ -185,6 +193,9 @@ class Metrics(object): unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) metric = state.entity_id.split(".")[1] + if '_' not in str(metric): + metric = state.entity_id.replace('.', '_') + try: int(metric.split("_")[-1]) metric = "_".join(metric.split("_")[:-1]) diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index dedc39ef3a2..bbc6e07f2b0 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -18,7 +18,7 @@ from homeassistant.loader import bind_hass from homeassistant.util import sanitize_filename import homeassistant.util.dt as dt_util -REQUIREMENTS = ['restrictedpython==4.0b2'] +REQUIREMENTS = ['restrictedpython==4.0b4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index 4d5f27082de..63e30a9491e 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -8,14 +8,18 @@ import logging import voluptuous as vol +from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA +from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_URL) + CONF_SENSORS, CONF_SWITCHES, CONF_URL, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) -from homeassistant.components.switch import SwitchDevice +from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyqwikswitch==0.4'] +REQUIREMENTS = ['pyqwikswitch==0.8'] _LOGGER = logging.getLogger(__name__) @@ -25,24 +29,63 @@ CONF_DIMMER_ADJUST = 'dimmer_adjust' CONF_BUTTON_EVENTS = 'button_events' CV_DIM_VALUE = vol.All(vol.Coerce(float), vol.Range(min=1, max=3)) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_URL, default='http://127.0.0.1:2020'): vol.Coerce(str), vol.Optional(CONF_DIMMER_ADJUST, default=1): CV_DIM_VALUE, - vol.Optional(CONF_BUTTON_EVENTS): vol.Coerce(str) + vol.Optional(CONF_BUTTON_EVENTS, default=[]): cv.ensure_list_csv, + vol.Optional(CONF_SENSORS, default=[]): vol.All( + cv.ensure_list, [vol.Schema({ + vol.Required('id'): str, + vol.Optional('channel', default=1): int, + vol.Required('name'): str, + vol.Required('type'): str, + vol.Optional('class'): DEVICE_CLASSES_SCHEMA, + vol.Optional('invert'): bool + })]), + vol.Optional(CONF_SWITCHES, default=[]): vol.All( + cv.ensure_list, [str]) })}, extra=vol.ALLOW_EXTRA) -QSUSB = {} -SUPPORT_QWIKSWITCH = SUPPORT_BRIGHTNESS +class QSEntity(Entity): + """Qwikswitch Entity base.""" + + def __init__(self, qsid, name): + """Initialize the QSEntity.""" + self._name = name + self.qsid = qsid + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def poll(self): + """QS sensors gets packets in update_packet.""" + return False + + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + return "qs{}".format(self.qsid) + + @callback + def update_packet(self, packet): + """Receive update packet from QSUSB. Match dispather_send signature.""" + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Listen for updates from QSUSb via dispatcher.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + self.qsid, self.update_packet) -class QSToggleEntity(object): - """Representation of a Qwikswitch Entity. - - Implement base QS methods. Modeled around HA ToggleEntity[1] & should only - be used in a class that extends both QSToggleEntity *and* ToggleEntity. +class QSToggleEntity(QSEntity): + """Representation of a Qwikswitch Toggle Entity. Implemented: - QSLight extends QSToggleEntity and Light[2] (ToggleEntity[1]) @@ -53,144 +96,124 @@ class QSToggleEntity(object): [3] /components/switch/__init__.py """ - def __init__(self, qsitem, qsusb): + def __init__(self, qsid, qsusb): """Initialize the ToggleEntity.""" - from pyqwikswitch import (QS_ID, QS_NAME, QSType, PQS_VALUE, PQS_TYPE) - self._id = qsitem[QS_ID] - self._name = qsitem[QS_NAME] - self._value = qsitem[PQS_VALUE] - self._qsusb = qsusb - self._dim = qsitem[PQS_TYPE] == QSType.dimmer - QSUSB[self._id] = self - - @property - def brightness(self): - """Return the brightness of this light between 0..100.""" - return self._value if self._dim else None - - # pylint: disable=no-self-use - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the light.""" - return self._name + self.device = qsusb.devices[qsid] + super().__init__(qsid, self.device.name) @property def is_on(self): """Check if device is on (non-zero).""" - return self._value > 0 + return self.device.value > 0 - def update_value(self, value): - """Decode the QSUSB value and update the Home assistant state.""" - if value != self._value: - self._value = value - # pylint: disable=no-member - super().schedule_update_ha_state() # Part of Entity/ToggleEntity - return self._value - - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" - newvalue = 255 - if ATTR_BRIGHTNESS in kwargs: - newvalue = kwargs[ATTR_BRIGHTNESS] - if self._qsusb.set(self._id, round(min(newvalue, 255)/2.55)) >= 0: - self.update_value(newvalue) + new = kwargs.get(ATTR_BRIGHTNESS, 255) + self.hass.data[DOMAIN].devices.set_value(self.qsid, new) - # pylint: disable=unused-argument - def turn_off(self, **kwargs): + async def async_turn_off(self, **_): """Turn the device off.""" - if self._qsusb.set(self._id, 0) >= 0: - self.update_value(0) + self.hass.data[DOMAIN].devices.set_value(self.qsid, 0) -class QSSwitch(QSToggleEntity, SwitchDevice): - """Switch based on a Qwikswitch relay module.""" +async def async_setup(hass, config): + """Qwiskswitch component setup.""" + from pyqwikswitch.async_ import QSUsb + from pyqwikswitch import CMD_BUTTONS, QS_CMD, QS_ID, QSType, SENSORS - pass - - -class QSLight(QSToggleEntity, Light): - """Light based on a Qwikswitch relay/dimmer module.""" - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_QWIKSWITCH - - -def setup(hass, config): - """Set up the QSUSB component.""" - from pyqwikswitch import ( - QSUsb, CMD_BUTTONS, QS_NAME, QS_ID, QS_CMD, PQS_VALUE, PQS_TYPE, - QSType) - - # Override which cmd's in /&listen packets will fire events + # Add cmd's to in /&listen packets will fire events # By default only buttons of type [TOGGLE,SCENE EXE,LEVEL] - cmd_buttons = config[DOMAIN].get(CONF_BUTTON_EVENTS, ','.join(CMD_BUTTONS)) - cmd_buttons = cmd_buttons.split(',') + cmd_buttons = set(CMD_BUTTONS) + for btn in config[DOMAIN][CONF_BUTTON_EVENTS]: + cmd_buttons.add(btn) url = config[DOMAIN][CONF_URL] dimmer_adjust = config[DOMAIN][CONF_DIMMER_ADJUST] + sensors = config[DOMAIN][CONF_SENSORS] + switches = config[DOMAIN][CONF_SWITCHES] - qsusb = QSUsb(url, _LOGGER, dimmer_adjust) + def callback_value_changed(_qsd, qsid, _val): + """Update entity values based on device change.""" + _LOGGER.debug("Dispatch %s (update from devices)", qsid) + hass.helpers.dispatcher.async_dispatcher_send(qsid, None) - def _stop(event): - """Stop the listener queue and clean up.""" - nonlocal qsusb - qsusb.stop() - qsusb = None - global QSUSB - QSUSB = {} - _LOGGER.info("Waiting for long poll to QSUSB to time out") - - hass.bus.listen(EVENT_HOMEASSISTANT_STOP, _stop) + session = async_get_clientsession(hass) + qsusb = QSUsb(url=url, dim_adj=dimmer_adjust, session=session, + callback_value_changed=callback_value_changed) # Discover all devices in QSUSB - devices = qsusb.devices() - QSUSB['switch'] = [] - QSUSB['light'] = [] - for item in devices: - if item[PQS_TYPE] == QSType.relay and (item[QS_NAME].lower() - .endswith(' switch')): - item[QS_NAME] = item[QS_NAME][:-7] # Remove ' switch' postfix - QSUSB['switch'].append(QSSwitch(item, qsusb)) - elif item[PQS_TYPE] in [QSType.relay, QSType.dimmer]: - QSUSB['light'].append(QSLight(item, qsusb)) + if not await qsusb.update_from_devices(): + return False + + hass.data[DOMAIN] = qsusb + + comps = {'switch': [], 'light': [], 'sensor': [], 'binary_sensor': []} + + try: + sensor_ids = [] + for sens in sensors: + _, _type = SENSORS[sens['type']] + sensor_ids.append(sens['id']) + if _type is bool: + comps['binary_sensor'].append(sens) + continue + comps['sensor'].append(sens) + for _key in ('invert', 'class'): + if _key in sens: + _LOGGER.warning( + "%s should only be used for binary_sensors: %s", + _key, sens) + + except KeyError: + _LOGGER.warning("Sensor validation failed") + + for qsid, dev in qsusb.devices.items(): + if qsid in switches: + if dev.qstype != QSType.relay: + _LOGGER.warning( + "You specified a switch that is not a relay %s", qsid) + continue + comps['switch'].append(qsid) + elif dev.qstype in (QSType.relay, QSType.dimmer): + comps['light'].append(qsid) else: - _LOGGER.warning("Ignored unknown QSUSB device: %s", item) + _LOGGER.warning("Ignored unknown QSUSB device: %s", dev) + continue # Load platforms - for comp_name in ('switch', 'light'): - if QSUSB[comp_name]: - load_platform(hass, comp_name, 'qwikswitch', {}, config) + for comp_name, comp_conf in comps.items(): + if comp_conf: + load_platform(hass, comp_name, DOMAIN, {DOMAIN: comp_conf}, config) - def qs_callback(item): + def callback_qs_listen(qspacket): """Typically a button press or update signal.""" - if qsusb is None: # Shutting down - _LOGGER.info("Button press or updating signal done") - return - # If button pressed, fire a hass event - if item.get(QS_CMD, '') in cmd_buttons: - hass.bus.fire('qwikswitch.button.' + item.get(QS_ID, '@no_id')) - return + if QS_ID in qspacket: + if qspacket.get(QS_CMD, '') in cmd_buttons: + hass.bus.async_fire( + 'qwikswitch.button.{}'.format(qspacket[QS_ID]), qspacket) + return + + if qspacket[QS_ID] in sensor_ids: + _LOGGER.debug("Dispatch %s ((%s))", qspacket[QS_ID], qspacket) + hass.helpers.dispatcher.async_dispatcher_send( + qspacket[QS_ID], qspacket) # Update all ha_objects - qsreply = qsusb.devices() - if qsreply is False: - return - for itm in qsreply: - if itm[QS_ID] in QSUSB: - QSUSB[itm[QS_ID]].update_value( - round(min(itm[PQS_VALUE], 100) * 2.55)) + hass.async_add_job(qsusb.update_from_devices) - def _start(event): + @callback + def async_start(_): """Start listening.""" - qsusb.listen(callback=qs_callback, timeout=30) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start) + hass.async_add_job(qsusb.listen, callback_qs_listen) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_start) + + @callback + def async_stop(_): + """Stop the listener.""" + hass.data[DOMAIN].stop() + + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, async_stop) return True diff --git a/homeassistant/components/rainbird.py b/homeassistant/components/rainbird.py index 76dda6fd366..bbce7f752af 100644 --- a/homeassistant/components/rainbird.py +++ b/homeassistant/components/rainbird.py @@ -11,7 +11,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.const import (CONF_HOST, CONF_PASSWORD) -REQUIREMENTS = ['pyrainbird==0.1.3'] +REQUIREMENTS = ['pyrainbird==0.1.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/raincloud.py b/homeassistant/components/raincloud.py index 505c3a7b2b0..308a945e942 100644 --- a/homeassistant/components/raincloud.py +++ b/homeassistant/components/raincloud.py @@ -168,7 +168,6 @@ class RainCloudEntity(Entity): """Return the state attributes.""" return { ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - 'current_time': self.data.current_time, 'identifier': self.data.serial, } diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py new file mode 100644 index 00000000000..7ee6b063720 --- /dev/null +++ b/homeassistant/components/rainmachine/__init__.py @@ -0,0 +1,226 @@ +""" +Support for RainMachine devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/rainmachine/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_BINARY_SENSORS, CONF_IP_ADDRESS, CONF_PASSWORD, + CONF_PORT, CONF_SENSORS, CONF_SSL, CONF_MONITORED_CONDITIONS, + CONF_SWITCHES) +from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['regenmaschine==0.4.2'] + +_LOGGER = logging.getLogger(__name__) + +DATA_RAINMACHINE = 'data_rainmachine' +DOMAIN = 'rainmachine' + +NOTIFICATION_ID = 'rainmachine_notification' +NOTIFICATION_TITLE = 'RainMachine Component Setup' + +DATA_UPDATE_TOPIC = '{0}_data_update'.format(DOMAIN) +PROGRAM_UPDATE_TOPIC = '{0}_program_update'.format(DOMAIN) + +CONF_PROGRAM_ID = 'program_id' +CONF_ZONE_ID = 'zone_id' +CONF_ZONE_RUN_TIME = 'zone_run_time' + +DEFAULT_ATTRIBUTION = 'Data provided by Green Electronics LLC' +DEFAULT_ICON = 'mdi:water' +DEFAULT_PORT = 8080 +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_SSL = True +DEFAULT_ZONE_RUN = 60 * 10 + +TYPE_FREEZE = 'freeze' +TYPE_FREEZE_PROTECTION = 'freeze_protection' +TYPE_FREEZE_TEMP = 'freeze_protect_temp' +TYPE_HOT_DAYS = 'extra_water_on_hot_days' +TYPE_HOURLY = 'hourly' +TYPE_MONTH = 'month' +TYPE_RAINDELAY = 'raindelay' +TYPE_RAINSENSOR = 'rainsensor' +TYPE_WEEKDAY = 'weekday' + +BINARY_SENSORS = { + TYPE_FREEZE: ('Freeze Restrictions', 'mdi:cancel'), + TYPE_FREEZE_PROTECTION: ('Freeze Protection', 'mdi:weather-snowy'), + TYPE_HOT_DAYS: ('Extra Water on Hot Days', 'mdi:thermometer-lines'), + TYPE_HOURLY: ('Hourly Restrictions', 'mdi:cancel'), + TYPE_MONTH: ('Month Restrictions', 'mdi:cancel'), + TYPE_RAINDELAY: ('Rain Delay Restrictions', 'mdi:cancel'), + TYPE_RAINSENSOR: ('Rain Sensor Restrictions', 'mdi:cancel'), + TYPE_WEEKDAY: ('Weekday Restrictions', 'mdi:cancel'), +} + +SENSORS = { + TYPE_FREEZE_TEMP: ('Freeze Protect Temperature', 'mdi:thermometer', '°C'), +} + +BINARY_SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]) +}) + +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]) +}) + +SERVICE_START_PROGRAM_SCHEMA = vol.Schema({ + vol.Required(CONF_PROGRAM_ID): cv.positive_int, +}) + +SERVICE_START_ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_ZONE_ID): cv.positive_int, + vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN): + cv.positive_int, +}) + +SERVICE_STOP_PROGRAM_SCHEMA = vol.Schema({ + vol.Required(CONF_PROGRAM_ID): cv.positive_int, +}) + +SERVICE_STOP_ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_ZONE_ID): cv.positive_int, +}) + +SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_ZONE_RUN_TIME): cv.positive_int}) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: + vol.Schema({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_BINARY_SENSORS, default={}): + BINARY_SENSOR_SCHEMA, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_SWITCHES, default={}): SWITCH_SCHEMA, + }) + }, + extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the RainMachine component.""" + from regenmaschine import Authenticator, Client + from regenmaschine.exceptions import RainMachineError + + conf = config[DOMAIN] + ip_address = conf[CONF_IP_ADDRESS] + password = conf[CONF_PASSWORD] + port = conf[CONF_PORT] + ssl = conf[CONF_SSL] + + try: + auth = Authenticator.create_local( + ip_address, password, port=port, https=ssl) + rainmachine = RainMachine(hass, Client(auth)) + rainmachine.update() + hass.data[DATA_RAINMACHINE] = rainmachine + except RainMachineError as exc: + _LOGGER.error('An error occurred: %s', str(exc)) + hass.components.persistent_notification.create( + 'Error: {0}
' + 'You will need to restart hass after fixing.' + ''.format(exc), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + for component, schema in [ + ('binary_sensor', conf[CONF_BINARY_SENSORS]), + ('sensor', conf[CONF_SENSORS]), + ('switch', conf[CONF_SWITCHES]), + ]: + discovery.load_platform(hass, component, DOMAIN, schema, config) + + def refresh(event_time): + """Refresh RainMachine data.""" + _LOGGER.debug('Updating RainMachine data') + hass.data[DATA_RAINMACHINE].update() + dispatcher_send(hass, DATA_UPDATE_TOPIC) + + track_time_interval(hass, refresh, DEFAULT_SCAN_INTERVAL) + + def start_program(service): + """Start a particular program.""" + rainmachine.client.programs.start(service.data[CONF_PROGRAM_ID]) + + def start_zone(service): + """Start a particular zone for a certain amount of time.""" + rainmachine.client.zones.start(service.data[CONF_ZONE_ID], + service.data[CONF_ZONE_RUN_TIME]) + + def stop_all(service): + """Stop all watering.""" + rainmachine.client.watering.stop_all() + + def stop_program(service): + """Stop a program.""" + rainmachine.client.programs.stop(service.data[CONF_PROGRAM_ID]) + + def stop_zone(service): + """Stop a zone.""" + rainmachine.client.zones.stop(service.data[CONF_ZONE_ID]) + + for service, method, schema in [ + ('start_program', start_program, SERVICE_START_PROGRAM_SCHEMA), + ('start_zone', start_zone, SERVICE_START_ZONE_SCHEMA), + ('stop_all', stop_all, {}), + ('stop_program', stop_program, SERVICE_STOP_PROGRAM_SCHEMA), + ('stop_zone', stop_zone, SERVICE_STOP_ZONE_SCHEMA) + ]: + hass.services.register(DOMAIN, service, method, schema=schema) + + return True + + +class RainMachine(object): + """Define a generic RainMachine object.""" + + def __init__(self, hass, client): + """Initialize.""" + self.client = client + self.device_mac = self.client.provision.wifi()['macAddress'] + self.restrictions = {} + + def update(self): + """Update sensor/binary sensor data.""" + self.restrictions.update({ + 'current': self.client.restrictions.current(), + 'global': self.client.restrictions.universal() + }) + + +class RainMachineEntity(Entity): + """Define a generic RainMachine entity.""" + + def __init__(self, rainmachine): + """Initialize.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._name = None + self.rainmachine = rainmachine + + @property + def device_state_attributes(self) -> dict: + """Return the state attributes.""" + return self._attrs + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name diff --git a/homeassistant/components/rainmachine/services.yaml b/homeassistant/components/rainmachine/services.yaml new file mode 100644 index 00000000000..a8c77628c8f --- /dev/null +++ b/homeassistant/components/rainmachine/services.yaml @@ -0,0 +1,32 @@ +# Describes the format for available RainMachine services + +--- +start_program: + description: Start a program. + fields: + program_id: + description: The program to start. + example: 3 +start_zone: + description: Start a zone for a set number of seconds. + fields: + zone_id: + description: The zone to start. + example: 3 + zone_run_time: + description: The number of seconds to run the zone. + example: 120 +stop_all: + description: Stop all watering activities. +stop_program: + description: Stop a program. + fields: + program_id: + description: The program to stop. + example: 3 +stop_zone: + description: Stop a zone. + fields: + zone_id: + description: The zone to stop. + example: 3 diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 392bccb56d4..38ba593261f 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -29,12 +29,13 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util +from homeassistant.loader import bind_hass from . import migration, purge from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.2.2'] +REQUIREMENTS = ['sqlalchemy==1.2.8'] _LOGGER = logging.getLogger(__name__) @@ -46,9 +47,8 @@ ATTR_KEEP_DAYS = 'keep_days' ATTR_REPACK = 'repack' SERVICE_PURGE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_KEEP_DAYS): - vol.All(vol.Coerce(int), vol.Range(min=0)), - vol.Optional(ATTR_REPACK, default=False): cv.boolean + vol.Optional(ATTR_KEEP_DAYS): vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Optional(ATTR_REPACK, default=False): cv.boolean, }) DEFAULT_URL = 'sqlite:///{hass_config_path}' @@ -63,16 +63,13 @@ CONNECT_RETRY_WAIT = 3 FILTER_SCHEMA = vol.Schema({ vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_DOMAINS): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_ENTITIES): cv.entity_ids, - vol.Optional(CONF_DOMAINS): - vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_EVENT_TYPES): - vol.All(cv.ensure_list, [cv.string]) + vol.Optional(CONF_EVENT_TYPES): vol.All(cv.ensure_list, [cv.string]), }), vol.Optional(CONF_INCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_DOMAINS): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_ENTITIES): cv.entity_ids, - vol.Optional(CONF_DOMAINS): - vol.All(cv.ensure_list, [cv.string]) }) }) @@ -87,14 +84,10 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def wait_connection_ready(hass): - """ - Wait till the connection is ready. - - Returns a coroutine object. - """ - return (yield from hass.data[DATA_INSTANCE].async_db_ready) +@bind_hass +async def wait_connection_ready(hass): + """Wait till the connection is ready.""" + return await hass.data[DATA_INSTANCE].async_db_ready def run_information(hass, point_in_time: Optional[datetime] = None): @@ -118,8 +111,7 @@ def run_information(hass, point_in_time: Optional[datetime] = None): return res -@asyncio.coroutine -def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the recorder.""" conf = config.get(DOMAIN, {}) keep_days = conf.get(CONF_PURGE_KEEP_DAYS) @@ -138,8 +130,7 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: instance.async_initialize() instance.start() - @asyncio.coroutine - def async_handle_purge_service(service): + async def async_handle_purge_service(service): """Handle calls to the purge service.""" instance.do_adhoc_purge(**service.data) @@ -147,7 +138,7 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DOMAIN, SERVICE_PURGE, async_handle_purge_service, schema=SERVICE_PURGE_SCHEMA) - return (yield from instance.async_db_ready) + return await instance.async_db_ready PurgeTask = namedtuple('PurgeTask', ['keep_days', 'repack']) @@ -258,7 +249,7 @@ class Recorder(threading.Thread): self.hass.add_job(register) result = hass_started.result() - # If shutdown happened before HASS finished starting + # If shutdown happened before Home Assistant finished starting if result is shutdown_task: return diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index 30141eaf5e6..e731d421e69 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -18,10 +18,11 @@ from homeassistant.components.remote import ( from homeassistant.const import ( CONF_NAME, CONF_HOST, CONF_TOKEN, CONF_TIMEOUT, ATTR_ENTITY_ID, ATTR_HIDDEN, CONF_COMMAND) +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['python-miio==0.3.8'] +REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] _LOGGER = logging.getLogger(__name__) @@ -78,10 +79,16 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # Check that we can communicate with device. try: - device.info() + device_info = device.info() + model = device_info.model + unique_id = "{}-{}".format(model, device_info.mac_address) + _LOGGER.info("%s %s %s detected", + model, + device_info.firmware_version, + device_info.hardware_version) except DeviceException as ex: - _LOGGER.error("Token not accepted by device : %s", ex) - return + _LOGGER.error("Device unavailable or token incorrect: %s", ex) + raise PlatformNotReady if DATA_KEY not in hass.data: hass.data[DATA_KEY] = {} @@ -93,9 +100,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hidden = config.get(ATTR_HIDDEN) - xiaomi_miio_remote = XiaomiMiioRemote( - friendly_name, device, slot, timeout, - hidden, config.get(CONF_COMMANDS)) + xiaomi_miio_remote = XiaomiMiioRemote(friendly_name, device, unique_id, + slot, timeout, hidden, + config.get(CONF_COMMANDS)) hass.data[DATA_KEY][host] = xiaomi_miio_remote @@ -131,7 +138,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): while (utcnow() - start_time) < timedelta(seconds=timeout): message = yield from hass.async_add_job( device.read, slot) - _LOGGER.debug("Message recieved from device: '%s'", message) + _LOGGER.debug("Message received from device: '%s'", message) if 'code' in message and message['code']: log_msg = "Received command is: {}".format(message['code']) @@ -158,17 +165,23 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class XiaomiMiioRemote(RemoteDevice): """Representation of a Xiaomi Miio Remote device.""" - def __init__(self, friendly_name, device, + def __init__(self, friendly_name, device, unique_id, slot, timeout, hidden, commands): """Initialize the remote.""" self._name = friendly_name self._device = device + self._unique_id = unique_id self._is_hidden = hidden self._slot = slot self._timeout = timeout self._state = False self._commands = commands + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + @property def name(self): """Return the name of the remote.""" diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index 439f938beb3..87e2a7a2331 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -22,7 +22,7 @@ from homeassistant.helpers.deprecation import get_deprecated from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['rflink==0.0.34'] +REQUIREMENTS = ['rflink==0.0.37'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index e7301836d7e..2f170a20646 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -4,23 +4,20 @@ Support for RFXtrx components. For more details about this component, please refer to the documentation at https://home-assistant.io/components/rfxtrx/ """ - import asyncio -import logging from collections import OrderedDict +import logging + import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.util import slugify from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - ATTR_ENTITY_ID, TEMP_CELSIUS, - CONF_DEVICES -) + ATTR_ENTITY_ID, ATTR_NAME, ATTR_STATE, CONF_DEVICE, CONF_DEVICES, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify -REQUIREMENTS = ['pyRFXtrx==0.21.1'] +REQUIREMENTS = ['pyRFXtrx==0.22.1'] DOMAIN = 'rfxtrx' @@ -29,8 +26,6 @@ DEFAULT_SIGNAL_REPETITIONS = 1 ATTR_AUTOMATIC_ADD = 'automatic_add' ATTR_DEVICE = 'device' ATTR_DEBUG = 'debug' -ATTR_STATE = 'state' -ATTR_NAME = 'name' ATTR_FIRE_EVENT = 'fire_event' ATTR_DATA_TYPE = 'data_type' ATTR_DUMMY = 'dummy' @@ -40,7 +35,6 @@ CONF_DATA_TYPE = 'data_type' CONF_SIGNAL_REPETITIONS = 'signal_repetitions' CONF_FIRE_EVENT = 'fire_event' CONF_DUMMY = 'dummy' -CONF_DEVICE = 'device' CONF_DEBUG = 'debug' CONF_OFF_DELAY = 'off_delay' EVENT_BUTTON_PRESSED = 'button_pressed' diff --git a/homeassistant/components/ring.py b/homeassistant/components/ring.py index 6e70ddb244d..1a15e22fca0 100644 --- a/homeassistant/components/ring.py +++ b/homeassistant/components/ring.py @@ -37,8 +37,8 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Set up the Ring component.""" conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] try: from ring_doorbell import Ring diff --git a/homeassistant/components/sabnzbd.py b/homeassistant/components/sabnzbd.py new file mode 100644 index 00000000000..a7b33b4c697 --- /dev/null +++ b/homeassistant/components/sabnzbd.py @@ -0,0 +1,254 @@ +""" +Support for monitoring an SABnzbd NZB client. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sabnzbd/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.discovery import SERVICE_SABNZBD +from homeassistant.const import ( + CONF_HOST, CONF_API_KEY, CONF_NAME, CONF_PORT, CONF_SENSORS, CONF_SSL) +from homeassistant.core import callback +from homeassistant.helpers import discovery +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.json import load_json, save_json + +REQUIREMENTS = ['pysabnzbd==1.0.1'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'sabnzbd' +DATA_SABNZBD = 'sabznbd' + +_CONFIGURING = {} + +ATTR_SPEED = 'speed' +BASE_URL_FORMAT = '{}://{}:{}/' +CONFIG_FILE = 'sabnzbd.conf' +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'SABnzbd' +DEFAULT_PORT = 8080 +DEFAULT_SPEED_LIMIT = '100' +DEFAULT_SSL = False + +UPDATE_INTERVAL = timedelta(seconds=30) + +SERVICE_PAUSE = 'pause' +SERVICE_RESUME = 'resume' +SERVICE_SET_SPEED = 'set_speed' + +SIGNAL_SABNZBD_UPDATED = 'sabnzbd_updated' + +SENSOR_TYPES = { + 'current_status': ['Status', None, 'status'], + 'speed': ['Speed', 'MB/s', 'kbpersec'], + 'queue_size': ['Queue', 'MB', 'mb'], + 'queue_remaining': ['Left', 'MB', 'mbleft'], + 'disk_size': ['Disk', 'GB', 'diskspacetotal1'], + 'disk_free': ['Disk Free', 'GB', 'diskspace1'], + 'queue_count': ['Queue Count', None, 'noofslots_total'], + 'day_size': ['Daily Total', 'GB', 'day_size'], + 'week_size': ['Weekly Total', 'GB', 'week_size'], + 'month_size': ['Monthly Total', 'GB', 'month_size'], + 'total_size': ['Total', 'GB', 'total_size'], +} + +SPEED_LIMIT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_SPEED, default=DEFAULT_SPEED_LIMIT): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SENSORS): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_check_sabnzbd(sab_api): + """Check if we can reach SABnzbd.""" + from pysabnzbd import SabnzbdApiException + + try: + await sab_api.check_available() + return True + except SabnzbdApiException: + _LOGGER.error("Connection to SABnzbd API failed") + return False + + +async def async_configure_sabnzbd(hass, config, use_ssl, name=DEFAULT_NAME, + api_key=None): + """Try to configure Sabnzbd and request api key if configuration fails.""" + from pysabnzbd import SabnzbdApi + + host = config[CONF_HOST] + port = config[CONF_PORT] + uri_scheme = 'https' if use_ssl else 'http' + base_url = BASE_URL_FORMAT.format(uri_scheme, host, port) + if api_key is None: + conf = await hass.async_add_job(load_json, + hass.config.path(CONFIG_FILE)) + api_key = conf.get(base_url, {}).get(CONF_API_KEY, '') + + sab_api = SabnzbdApi(base_url, api_key) + if await async_check_sabnzbd(sab_api): + async_setup_sabnzbd(hass, sab_api, config, name) + else: + async_request_configuration(hass, config, base_url) + + +async def async_setup(hass, config): + """Setup the SABnzbd component.""" + async def sabnzbd_discovered(service, info): + """Handle service discovery.""" + ssl = info.get('properties', {}).get('https', '0') == '1' + await async_configure_sabnzbd(hass, info, ssl) + + discovery.async_listen(hass, SERVICE_SABNZBD, sabnzbd_discovered) + + conf = config.get(DOMAIN) + if conf is not None: + use_ssl = conf.get(CONF_SSL) + name = conf.get(CONF_NAME) + api_key = conf.get(CONF_API_KEY) + await async_configure_sabnzbd(hass, conf, use_ssl, name, api_key) + return True + + +@callback +def async_setup_sabnzbd(hass, sab_api, config, name): + """Setup SABnzbd sensors and services.""" + sab_api_data = SabnzbdApiData(sab_api, name, config.get(CONF_SENSORS, {})) + + if config.get(CONF_SENSORS): + hass.data[DATA_SABNZBD] = sab_api_data + hass.async_add_job( + discovery.async_load_platform(hass, 'sensor', DOMAIN, {}, config)) + + async def async_service_handler(service): + """Handle service calls.""" + if service.service == SERVICE_PAUSE: + await sab_api_data.async_pause_queue() + elif service.service == SERVICE_RESUME: + await sab_api_data.async_resume_queue() + elif service.service == SERVICE_SET_SPEED: + speed = service.data.get(ATTR_SPEED) + await sab_api_data.async_set_queue_speed(speed) + + hass.services.async_register(DOMAIN, SERVICE_PAUSE, + async_service_handler, + schema=vol.Schema({})) + + hass.services.async_register(DOMAIN, SERVICE_RESUME, + async_service_handler, + schema=vol.Schema({})) + + hass.services.async_register(DOMAIN, SERVICE_SET_SPEED, + async_service_handler, + schema=SPEED_LIMIT_SCHEMA) + + async def async_update_sabnzbd(now): + """Refresh SABnzbd queue data.""" + from pysabnzbd import SabnzbdApiException + try: + await sab_api.refresh_data() + async_dispatcher_send(hass, SIGNAL_SABNZBD_UPDATED, None) + except SabnzbdApiException as err: + _LOGGER.error(err) + + async_track_time_interval(hass, async_update_sabnzbd, UPDATE_INTERVAL) + + +@callback +def async_request_configuration(hass, config, host): + """Request configuration steps from the user.""" + from pysabnzbd import SabnzbdApi + + configurator = hass.components.configurator + # We got an error if this method is called while we are configuring + if host in _CONFIGURING: + configurator.async_notify_errors( + _CONFIGURING[host], + 'Failed to register, please try again.') + + return + + async def async_configuration_callback(data): + """Handle configuration changes.""" + api_key = data.get(CONF_API_KEY) + sab_api = SabnzbdApi(host, api_key) + if not await async_check_sabnzbd(sab_api): + return + + def success(): + """Setup was successful.""" + conf = load_json(hass.config.path(CONFIG_FILE)) + conf[host] = {CONF_API_KEY: api_key} + save_json(hass.config.path(CONFIG_FILE), conf) + req_config = _CONFIGURING.pop(host) + configurator.request_done(req_config) + + hass.async_add_job(success) + async_setup_sabnzbd(hass, sab_api, config, + config.get(CONF_NAME, DEFAULT_NAME)) + + _CONFIGURING[host] = configurator.async_request_config( + DEFAULT_NAME, + async_configuration_callback, + description='Enter the API Key', + submit_caption='Confirm', + fields=[{'id': CONF_API_KEY, 'name': 'API Key', 'type': ''}] + ) + + +class SabnzbdApiData: + """Class for storing/refreshing sabnzbd api queue data.""" + + def __init__(self, sab_api, name, sensors): + """Initialize component.""" + self.sab_api = sab_api + self.name = name + self.sensors = sensors + + async def async_pause_queue(self): + """Pause Sabnzbd queue.""" + from pysabnzbd import SabnzbdApiException + try: + return await self.sab_api.pause_queue() + except SabnzbdApiException as err: + _LOGGER.error(err) + return False + + async def async_resume_queue(self): + """Resume Sabnzbd queue.""" + from pysabnzbd import SabnzbdApiException + try: + return await self.sab_api.resume_queue() + except SabnzbdApiException as err: + _LOGGER.error(err) + return False + + async def async_set_queue_speed(self, limit): + """Set speed limit for the Sabnzbd queue.""" + from pysabnzbd import SabnzbdApiException + try: + return await self.sab_api.set_speed_limit(limit) + except SabnzbdApiException as err: + _LOGGER.error(err) + return False + + def get_queue_field(self, field): + """Return the value for the given field from the Sabnzbd queue.""" + return self.sab_api.queue.get(field) diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index 8f0b9d5c7ab..7b76836555c 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/scene/ """ import asyncio +import importlib import logging import voluptuous as vol @@ -16,7 +17,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.state import HASS_DOMAIN -from homeassistant.loader import get_platform DOMAIN = 'scene' STATE = 'scening' @@ -34,20 +34,24 @@ def _hass_domain_validator(config): def _platform_validator(config): """Validate it is a valid platform.""" - p_name = config[CONF_PLATFORM] - platform = get_platform(DOMAIN, p_name) + try: + platform = importlib.import_module( + 'homeassistant.components.scene.{}'.format( + config[CONF_PLATFORM])) + except ImportError: + raise vol.Invalid('Invalid platform specified') from None if not hasattr(platform, 'PLATFORM_SCHEMA'): return config - return getattr(platform, 'PLATFORM_SCHEMA')(config) + return platform.PLATFORM_SCHEMA(config) PLATFORM_SCHEMA = vol.Schema( vol.All( _hass_domain_validator, vol.Schema({ - vol.Required(CONF_PLATFORM): cv.platform_validator(DOMAIN) + vol.Required(CONF_PLATFORM): str }, extra=vol.ALLOW_EXTRA), _platform_validator ), extra=vol.ALLOW_EXTRA) @@ -71,7 +75,7 @@ def activate(hass, entity_id=None): async def async_setup(hass, config): """Set up the scenes.""" logger = logging.getLogger(__name__) - component = EntityComponent(logger, DOMAIN, hass) + component = hass.data[DOMAIN] = EntityComponent(logger, DOMAIN, hass) await component.async_setup(config) @@ -90,6 +94,16 @@ async def async_setup(hass, 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) + + class Scene(Entity): """A scene is a group of entities and the states we want them to be.""" diff --git a/homeassistant/components/scene/deconz.py b/homeassistant/components/scene/deconz.py index dffc7720776..3eb73736717 100644 --- a/homeassistant/components/scene/deconz.py +++ b/homeassistant/components/scene/deconz.py @@ -13,10 +13,12 @@ DEPENDENCIES = ['deconz'] async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up scenes for deCONZ component.""" - if discovery_info is None: - return + """Old way of setting up deCONZ scenes.""" + pass + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up scenes for deCONZ component.""" scenes = hass.data[DATA_DECONZ].scenes entities = [] diff --git a/homeassistant/components/sensor/.translations/season.bg.json b/homeassistant/components/sensor/.translations/season.bg.json new file mode 100644 index 00000000000..e3865ca42e5 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.bg.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "\u0415\u0441\u0435\u043d", + "spring": "\u041f\u0440\u043e\u043b\u0435\u0442", + "summer": "\u041b\u044f\u0442\u043e", + "winter": "\u0417\u0438\u043c\u0430" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.cs.json b/homeassistant/components/sensor/.translations/season.cs.json new file mode 100644 index 00000000000..e2d7e7919be --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.cs.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Podzim", + "spring": "Jaro", + "summer": "L\u00e9to", + "winter": "Zima" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.cy.json b/homeassistant/components/sensor/.translations/season.cy.json new file mode 100644 index 00000000000..0d1553ac3ea --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.cy.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Hydref", + "spring": "Gwanwyn", + "summer": "Haf", + "winter": "Gaeaf" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.da.json b/homeassistant/components/sensor/.translations/season.da.json new file mode 100644 index 00000000000..9cded2f9c0f --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.da.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Efter\u00e5r", + "spring": "For\u00e5r", + "summer": "Sommer", + "winter": "Vinter" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.de.json b/homeassistant/components/sensor/.translations/season.de.json new file mode 100644 index 00000000000..50d702340b9 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.de.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Herbst", + "spring": "Fr\u00fchling", + "summer": "Sommer", + "winter": "Winter" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.es.json b/homeassistant/components/sensor/.translations/season.es.json new file mode 100644 index 00000000000..65df6a58b10 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.es.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Oto\u00f1o", + "spring": "Primavera", + "summer": "Verano", + "winter": "Invierno" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.fi.json b/homeassistant/components/sensor/.translations/season.fi.json new file mode 100644 index 00000000000..f01f6451549 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.fi.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Syksy", + "spring": "Kev\u00e4t", + "summer": "Kes\u00e4", + "winter": "Talvi" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.hu.json b/homeassistant/components/sensor/.translations/season.hu.json new file mode 100644 index 00000000000..63596b09784 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.hu.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "\u0150sz", + "spring": "Tavasz", + "summer": "Ny\u00e1r", + "winter": "T\u00e9l" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.it.json b/homeassistant/components/sensor/.translations/season.it.json new file mode 100644 index 00000000000..d9138f6b16e --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.it.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Autunno", + "spring": "Primavera", + "summer": "Estate", + "winter": "Inverno" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.ja.json b/homeassistant/components/sensor/.translations/season.ja.json new file mode 100644 index 00000000000..e441b1aa8ac --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.ja.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "\u79cb", + "spring": "\u6625", + "summer": "\u590f", + "winter": "\u51ac" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.ko.json b/homeassistant/components/sensor/.translations/season.ko.json new file mode 100644 index 00000000000..f2bf0a7bae5 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.ko.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "\uac00\uc744", + "spring": "\ubd04", + "summer": "\uc5ec\ub984", + "winter": "\uaca8\uc6b8" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.lb.json b/homeassistant/components/sensor/.translations/season.lb.json new file mode 100644 index 00000000000..f33afde7a07 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.lb.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Hierscht", + "spring": "Fr\u00e9ijoer", + "summer": "Summer", + "winter": "Wanter" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.nl.json b/homeassistant/components/sensor/.translations/season.nl.json new file mode 100644 index 00000000000..6054a8e2be5 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.nl.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Herfst", + "spring": "Lente", + "summer": "Zomer", + "winter": "Winter" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.no.json b/homeassistant/components/sensor/.translations/season.no.json new file mode 100644 index 00000000000..9d520dae6a5 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.no.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "H\u00f8st", + "spring": "V\u00e5r", + "summer": "Sommer", + "winter": "Vinter" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.pl.json b/homeassistant/components/sensor/.translations/season.pl.json new file mode 100644 index 00000000000..f5a7da57e7f --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.pl.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Jesie\u0144", + "spring": "Wiosna", + "summer": "Lato", + "winter": "Zima" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.pt.json b/homeassistant/components/sensor/.translations/season.pt.json new file mode 100644 index 00000000000..fde45ad6c8e --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.pt.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Outono", + "spring": "Primavera", + "summer": "Ver\u00e3o", + "winter": "Inverno" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.ro.json b/homeassistant/components/sensor/.translations/season.ro.json new file mode 100644 index 00000000000..04f90318290 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.ro.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Toamn\u0103", + "spring": "Prim\u0103var\u0103", + "summer": "Var\u0103", + "winter": "Iarn\u0103" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.ru.json b/homeassistant/components/sensor/.translations/season.ru.json new file mode 100644 index 00000000000..2b04886b72d --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.ru.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "\u041e\u0441\u0435\u043d\u044c", + "spring": "\u0412\u0435\u0441\u043d\u0430", + "summer": "\u041b\u0435\u0442\u043e", + "winter": "\u0417\u0438\u043c\u0430" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.sl.json b/homeassistant/components/sensor/.translations/season.sl.json new file mode 100644 index 00000000000..f715a3ec13a --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.sl.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Jesen", + "spring": "Pomlad", + "summer": "Poletje", + "winter": "Zima" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.sv.json b/homeassistant/components/sensor/.translations/season.sv.json new file mode 100644 index 00000000000..02332d76906 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.sv.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "H\u00f6st", + "spring": "V\u00e5r", + "summer": "Sommar", + "winter": "Vinter" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.th.json b/homeassistant/components/sensor/.translations/season.th.json new file mode 100644 index 00000000000..09799730389 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.th.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "\u0e24\u0e14\u0e39\u0e43\u0e1a\u0e44\u0e21\u0e49\u0e23\u0e48\u0e27\u0e07", + "spring": "\u0e24\u0e14\u0e39\u0e43\u0e1a\u0e44\u0e21\u0e49\u0e1c\u0e25\u0e34", + "summer": "\u0e24\u0e14\u0e39\u0e23\u0e49\u0e2d\u0e19", + "winter": "\u0e24\u0e14\u0e39\u0e2b\u0e19\u0e32\u0e27" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.vi.json b/homeassistant/components/sensor/.translations/season.vi.json new file mode 100644 index 00000000000..a3bb21dee27 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.vi.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "M\u00f9a thu", + "spring": "M\u00f9a xu\u00e2n", + "summer": "M\u00f9a h\u00e8", + "winter": "M\u00f9a \u0111\u00f4ng" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.zh-Hans.json b/homeassistant/components/sensor/.translations/season.zh-Hans.json new file mode 100644 index 00000000000..78801f4b1df --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.zh-Hans.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "\u79cb\u5b63", + "spring": "\u6625\u5b63", + "summer": "\u590f\u5b63", + "winter": "\u51ac\u5b63" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.zh-Hant.json b/homeassistant/components/sensor/.translations/season.zh-Hant.json new file mode 100644 index 00000000000..78801f4b1df --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.zh-Hant.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "\u79cb\u5b63", + "spring": "\u6625\u5b63", + "summer": "\u590f\u5b63", + "winter": "\u51ac\u5b63" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index e0bf3c86b05..8550d175b63 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -8,8 +8,13 @@ https://home-assistant.io/components/sensor/ from datetime import timedelta import logging +import voluptuous as vol + from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE) _LOGGER = logging.getLogger(__name__) @@ -18,12 +23,30 @@ DOMAIN = 'sensor' ENTITY_ID_FORMAT = DOMAIN + '.{}' SCAN_INTERVAL = timedelta(seconds=30) +DEVICE_CLASSES = [ + DEVICE_CLASS_BATTERY, # % of battery that is left + DEVICE_CLASS_HUMIDITY, # % of humidity in the air + DEVICE_CLASS_ILLUMINANCE, # current light level (lx/lm) + DEVICE_CLASS_TEMPERATURE, # temperature (C/F) +] + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) async def async_setup(hass, config): """Track states and offer events for sensors.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, 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) diff --git a/homeassistant/components/sensor/abode.py b/homeassistant/components/sensor/abode.py index 1a700e24de6..b51ab288c1a 100644 --- a/homeassistant/components/sensor/abode.py +++ b/homeassistant/components/sensor/abode.py @@ -7,6 +7,8 @@ https://home-assistant.io/components/sensor.abode/ import logging from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE) _LOGGER = logging.getLogger(__name__) @@ -14,9 +16,9 @@ DEPENDENCIES = ['abode'] # Sensor types: Name, icon SENSOR_TYPES = { - 'temp': ['Temperature', 'thermometer'], - 'humidity': ['Humidity', 'water-percent'], - 'lux': ['Lux', 'lightbulb'], + 'temp': ['Temperature', DEVICE_CLASS_TEMPERATURE], + 'humidity': ['Humidity', DEVICE_CLASS_HUMIDITY], + 'lux': ['Lux', DEVICE_CLASS_ILLUMINANCE], } @@ -46,20 +48,20 @@ class AbodeSensor(AbodeDevice): """Initialize a sensor for an Abode device.""" super().__init__(data, device) self._sensor_type = sensor_type - self._icon = 'mdi:{}'.format(SENSOR_TYPES[self._sensor_type][1]) self._name = '{0} {1}'.format( self._device.name, SENSOR_TYPES[self._sensor_type][0]) - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + self._device_class = SENSOR_TYPES[self._sensor_type][1] @property def name(self): """Return the name of the sensor.""" return self._name + @property + def device_class(self): + """Return the device class.""" + return self._device_class + @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py index 896497a93d5..77d8ba9322f 100644 --- a/homeassistant/components/sensor/alpha_vantage.py +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -15,7 +15,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['alpha_vantage==1.9.0'] +REQUIREMENTS = ['alpha_vantage==2.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/bh1750.py b/homeassistant/components/sensor/bh1750.py index 0c538a6cfcc..6d34d4ea9f8 100644 --- a/homeassistant/components/sensor/bh1750.py +++ b/homeassistant/components/sensor/bh1750.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, DEVICE_CLASS_ILLUMINANCE from homeassistant.helpers.entity import Entity REQUIREMENTS = ['i2csense==0.0.4', @@ -130,7 +130,7 @@ class BH1750Sensor(Entity): @property def device_class(self) -> str: """Return the class of this device, from component DEVICE_CLASSES.""" - return 'light' + return DEVICE_CLASS_ILLUMINANCE @asyncio.coroutine def async_update(self): diff --git a/homeassistant/components/sensor/bitcoin.py b/homeassistant/components/sensor/bitcoin.py index 8bed72a67c2..38d2226012c 100644 --- a/homeassistant/components/sensor/bitcoin.py +++ b/homeassistant/components/sensor/bitcoin.py @@ -15,7 +15,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['blockchain==1.4.0'] +REQUIREMENTS = ['blockchain==1.4.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/bloomsky.py b/homeassistant/components/sensor/bloomsky.py index ce44abdb087..b460498c901 100644 --- a/homeassistant/components/sensor/bloomsky.py +++ b/homeassistant/components/sensor/bloomsky.py @@ -11,7 +11,6 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (TEMP_FAHRENHEIT, CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity -from homeassistant.loader import get_component import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -45,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the available BloomSky weather sensors.""" - bloomsky = get_component('bloomsky') + bloomsky = hass.components.bloomsky # Default needed in case of discovery sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) diff --git a/homeassistant/components/sensor/bmw_connected_drive.py b/homeassistant/components/sensor/bmw_connected_drive.py index 26bfd19e6fc..e3331cdc763 100644 --- a/homeassistant/components/sensor/bmw_connected_drive.py +++ b/homeassistant/components/sensor/bmw_connected_drive.py @@ -4,24 +4,27 @@ 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/sensor.bmw_connected_drive/ """ -import logging import asyncio +import logging from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level DEPENDENCIES = ['bmw_connected_drive'] _LOGGER = logging.getLogger(__name__) -LENGTH_ATTRIBUTES = [ - 'remaining_range_fuel', - 'mileage', - ] - -VALID_ATTRIBUTES = LENGTH_ATTRIBUTES + [ - 'remaining_fuel', -] +ATTR_TO_HA = { + 'mileage': ['mdi:speedometer', 'km'], + 'remaining_range_total': ['mdi:ruler', 'km'], + 'remaining_range_electric': ['mdi:ruler', 'km'], + 'remaining_range_fuel': ['mdi:ruler', 'km'], + 'max_range_electric': ['mdi:ruler', 'km'], + 'remaining_fuel': ['mdi:gas-station', 'l'], + 'charging_time_remaining': ['mdi:update', 'h'], + 'charging_status': ['mdi:battery-charging', None] +} def setup_platform(hass, config, add_devices, discovery_info=None): @@ -32,10 +35,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for account in accounts: for vehicle in account.account.vehicles: - for sensor in VALID_ATTRIBUTES: - device = BMWConnectedDriveSensor(account, vehicle, sensor) + for attribute_name in vehicle.drive_train_attributes: + device = BMWConnectedDriveSensor(account, vehicle, + attribute_name) devices.append(device) - add_devices(devices) + device = BMWConnectedDriveSensor(account, vehicle, 'mileage') + devices.append(device) + add_devices(devices, True) class BMWConnectedDriveSensor(Entity): @@ -47,19 +53,39 @@ class BMWConnectedDriveSensor(Entity): self._account = account self._attribute = attribute self._state = None - self._unit_of_measurement = None - self._name = '{} {}'.format(self._vehicle.modelName, self._attribute) + self._name = '{} {}'.format(self._vehicle.name, self._attribute) + self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute) @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 sensor.""" + return self._unique_id + @property def name(self) -> str: """Return the name of the sensor.""" return self._name + @property + def icon(self): + """Icon to use in the frontend, if any.""" + from bimmer_connected.state import ChargingState + vehicle_state = self._vehicle.state + charging_state = vehicle_state.charging_status in \ + [ChargingState.CHARGING] + + if self._attribute == 'charging_level_hv': + return icon_for_battery_level( + battery_level=vehicle_state.charging_level_hv, + charging=charging_state) + icon, _ = ATTR_TO_HA.get(self._attribute, [None, None]) + return icon + @property def state(self): """Return the state of the sensor. @@ -72,22 +98,28 @@ class BMWConnectedDriveSensor(Entity): @property def unit_of_measurement(self) -> str: """Get the unit of measurement.""" - return self._unit_of_measurement + _, unit = ATTR_TO_HA.get(self._attribute, [None, None]) + return unit + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + 'car': self._vehicle.name + } def update(self) -> None: """Read new state data from the library.""" - _LOGGER.debug('Updating %s', self.entity_id) + _LOGGER.debug('Updating %s', self._vehicle.name) vehicle_state = self._vehicle.state - self._state = getattr(vehicle_state, self._attribute) - - if self._attribute in LENGTH_ATTRIBUTES: - self._unit_of_measurement = vehicle_state.unit_of_length - elif self._attribute == 'remaining_fuel': - self._unit_of_measurement = vehicle_state.unit_of_volume + if self._attribute == 'charging_status': + self._state = getattr(vehicle_state, self._attribute).value else: - self._unit_of_measurement = None + self._state = getattr(vehicle_state, self._attribute) - self.schedule_update_ha_state() + def update_callback(self): + """Schedule a state update.""" + self.schedule_update_ha_state(True) @asyncio.coroutine def async_added_to_hass(self): @@ -95,5 +127,4 @@ class BMWConnectedDriveSensor(Entity): Show latest data after startup. """ - self._account.add_update_listener(self.update) - yield from self.hass.async_add_job(self.update) + self._account.add_update_listener(self.update_callback) diff --git a/homeassistant/components/sensor/bom.py b/homeassistant/components/sensor/bom.py index 272d5d1e0b8..5cec528d26a 100644 --- a/homeassistant/components/sensor/bom.py +++ b/homeassistant/components/sensor/bom.py @@ -19,8 +19,8 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, STATE_UNKNOWN, CONF_NAME, - ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE) + CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, CONF_NAME, ATTR_ATTRIBUTION, + CONF_LATITUDE, CONF_LONGITUDE) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -28,15 +28,19 @@ import homeassistant.helpers.config_validation as cv _RESOURCE = 'http://www.bom.gov.au/fwo/{}/{}.{}.json' _LOGGER = logging.getLogger(__name__) +ATTR_LAST_UPDATE = 'last_update' +ATTR_SENSOR_ID = 'sensor_id' +ATTR_STATION_ID = 'station_id' +ATTR_STATION_NAME = 'station_name' +ATTR_ZONE_ID = 'zone_id' + CONF_ATTRIBUTION = "Data provided by the Australian Bureau of Meteorology" CONF_STATION = 'station' CONF_ZONE_ID = 'zone_id' CONF_WMO_ID = 'wmo_id' -MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=60) -LAST_UPDATE = 0 +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=35) -# Sensor types are defined like: Name, units SENSOR_TYPES = { 'wmo': ['wmo', None], 'name': ['Station Name', None], @@ -71,7 +75,7 @@ SENSOR_TYPES = { 'weather': ['Weather', None], 'wind_dir': ['Wind Direction', None], 'wind_spd_kmh': ['Wind Speed kmh', 'km/h'], - 'wind_spd_kt': ['Wind Direction kt', 'kt'] + 'wind_spd_kt': ['Wind Speed kt', 'kt'] } @@ -99,6 +103,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the BOM sensor.""" station = config.get(CONF_STATION) zone_id, wmo_id = config.get(CONF_ZONE_ID), config.get(CONF_WMO_ID) + if station is not None: if zone_id and wmo_id: _LOGGER.warning( @@ -112,25 +117,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass.config.config_dir) if station is None: _LOGGER.error("Could not get BOM weather station from lat/lon") - return False + return + + bom_data = BOMCurrentData(hass, station) - rest = BOMCurrentData(hass, station) try: - rest.update() + bom_data.update() except ValueError as err: - _LOGGER.error("Received error from BOM_Current: %s", err) - return False - add_devices([BOMCurrentSensor(rest, variable, config.get(CONF_NAME)) + _LOGGER.error("Received error from BOM Current: %s", err) + return + + add_devices([BOMCurrentSensor(bom_data, variable, config.get(CONF_NAME)) for variable in config[CONF_MONITORED_CONDITIONS]]) - return True class BOMCurrentSensor(Entity): """Implementation of a BOM current sensor.""" - def __init__(self, rest, condition, stationname): + def __init__(self, bom_data, condition, stationname): """Initialize the sensor.""" - self.rest = rest + self.bom_data = bom_data self._condition = condition self.stationname = stationname @@ -146,22 +152,22 @@ class BOMCurrentSensor(Entity): @property def state(self): """Return the state of the sensor.""" - if self.rest.data and self._condition in self.rest.data: - return self.rest.data[self._condition] - - return STATE_UNKNOWN + return self.bom_data.get_reading(self._condition) @property def device_state_attributes(self): """Return the state attributes of the device.""" - attr = {} - attr['Sensor Id'] = self._condition - attr['Zone Id'] = self.rest.data['history_product'] - attr['Station Id'] = self.rest.data['wmo'] - attr['Station Name'] = self.rest.data['name'] - attr['Last Update'] = datetime.datetime.strptime(str( - self.rest.data['local_date_time_full']), '%Y%m%d%H%M%S') - attr[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + attr = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_LAST_UPDATE: datetime.datetime.strptime( + str(self.bom_data.latest_data['local_date_time_full']), + '%Y%m%d%H%M%S'), + ATTR_SENSOR_ID: self._condition, + ATTR_STATION_ID: self.bom_data.latest_data['wmo'], + ATTR_STATION_NAME: self.bom_data.latest_data['name'], + ATTR_ZONE_ID: self.bom_data.latest_data['history_product'], + } + return attr @property @@ -171,7 +177,7 @@ class BOMCurrentSensor(Entity): def update(self): """Update current conditions.""" - self.rest.update() + self.bom_data.update() class BOMCurrentData(object): @@ -181,34 +187,44 @@ class BOMCurrentData(object): """Initialize the data object.""" self._hass = hass self._zone_id, self._wmo_id = station_id.split('.') - self.data = None - self._lastupdate = LAST_UPDATE + self._data = None def _build_url(self): + """Build the URL for the requests.""" url = _RESOURCE.format(self._zone_id, self._zone_id, self._wmo_id) - _LOGGER.info("BOM URL %s", url) + _LOGGER.debug("BOM URL: %s", url) return url + @property + def latest_data(self): + """Return the latest data object.""" + if self._data: + return self._data[0] + return None + + def get_reading(self, condition): + """Return the value for the given condition. + + BOM weather publishes condition readings for weather (and a few other + conditions) at intervals throughout the day. To avoid a `-` value in + the frontend for these conditions, we traverse the historical data + for the latest value that is not `-`. + + Iterators are used in this method to avoid iterating needlessly + iterating through the entire BOM provided dataset. + """ + condition_readings = (entry[condition] for entry in self._data) + return next((x for x in condition_readings if x != '-'), None) + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from BOM.""" - if self._lastupdate != 0 and \ - ((datetime.datetime.now() - self._lastupdate) < - datetime.timedelta(minutes=35)): - _LOGGER.info( - "BOM was updated %s minutes ago, skipping update as" - " < 35 minutes", (datetime.datetime.now() - self._lastupdate)) - return self._lastupdate - try: result = requests.get(self._build_url(), timeout=10).json() - self.data = result['observations']['data'][0] - self._lastupdate = datetime.datetime.strptime( - str(self.data['local_date_time_full']), '%Y%m%d%H%M%S') - return self._lastupdate + self._data = result['observations']['data'] except ValueError as err: _LOGGER.error("Check BOM %s", err.args) - self.data = None + self._data = None raise @@ -252,7 +268,7 @@ def _get_bom_stations(): def bom_stations(cache_dir): """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config. - Results from internet requests are cached as compressed json, making + Results from internet requests are cached as compressed JSON, making subsequent calls very much faster. """ cache_file = os.path.join(cache_dir, '.bom-stations.json.gz') @@ -272,7 +288,7 @@ def closest_station(lat, lon, cache_dir): stations = bom_stations(cache_dir) def comparable_dist(wmo_id): - """Create a psudeo-distance from lat/lon.""" + """Create a psudeo-distance from latitude/longitude.""" station_lat, station_lon = stations[wmo_id] return (lat - station_lat) ** 2 + (lon - station_lon) ** 2 diff --git a/homeassistant/components/sensor/broadlink.py b/homeassistant/components/sensor/broadlink.py index 47cefe50aec..9376687cf13 100644 --- a/homeassistant/components/sensor/broadlink.py +++ b/homeassistant/components/sensor/broadlink.py @@ -19,9 +19,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = [ - 'https://github.com/balloob/python-broadlink/archive/' - '3580ff2eaccd267846f14246d6ede6e30671f7c6.zip#broadlink==0.5.1'] +REQUIREMENTS = ['broadlink==0.9.0'] _LOGGER = logging.getLogger(__name__) @@ -58,9 +56,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) timeout = config.get(CONF_TIMEOUT) update_interval = config.get(CONF_UPDATE_INTERVAL) - broadlink_data = BroadlinkData(update_interval, host, mac_addr, timeout) - dev = [] for variable in config[CONF_MONITORED_CONDITIONS]: dev.append(BroadlinkSensor(name, broadlink_data, variable)) @@ -106,10 +102,11 @@ class BroadlinkData(object): def __init__(self, interval, ip_addr, mac_addr, timeout): """Initialize the data object.""" - import broadlink self.data = None - self._device = broadlink.a1((ip_addr, 80), mac_addr) - self._device.timeout = timeout + self.ip_addr = ip_addr + self.mac_addr = mac_addr + self.timeout = timeout + self._connect() self._schema = vol.Schema({ vol.Optional('temperature'): vol.Range(min=-50, max=150), vol.Optional('humidity'): vol.Range(min=0, max=100), @@ -121,6 +118,11 @@ class BroadlinkData(object): if not self._auth(): _LOGGER.warning("Failed to connect to device") + def _connect(self): + import broadlink + self._device = broadlink.a1((self.ip_addr, 80), self.mac_addr, None) + self._device.timeout = self.timeout + def _update(self, retry=3): try: data = self._device.check_sensors_raw() @@ -142,5 +144,6 @@ class BroadlinkData(object): except socket.timeout: auth = False if not auth and retry > 0: + self._connect() return self._auth(retry-1) return auth diff --git a/homeassistant/components/sensor/buienradar.py b/homeassistant/components/sensor/buienradar.py index 5d74f038eaa..590d5a8f1ce 100644 --- a/homeassistant/components/sensor/buienradar.py +++ b/homeassistant/components/sensor/buienradar.py @@ -161,7 +161,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): dev = [] for sensor_type in config[CONF_MONITORED_CONDITIONS]: - dev.append(BrSensor(sensor_type, config.get(CONF_NAME, 'br'))) + dev.append(BrSensor(sensor_type, config.get(CONF_NAME, 'br'), + coordinates)) async_add_devices(dev) data = BrData(hass, coordinates, timeframe, dev) @@ -172,9 +173,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class BrSensor(Entity): """Representation of an Buienradar sensor.""" - def __init__(self, sensor_type, client_name): + def __init__(self, sensor_type, client_name, coordinates): """Initialize the sensor.""" - from buienradar.buienradar import (PRECIPITATION_FORECAST) + from buienradar.buienradar import (PRECIPITATION_FORECAST, CONDITION) self.client_name = client_name self._name = SENSOR_TYPES[sensor_type][0] @@ -185,10 +186,22 @@ class BrSensor(Entity): self._attribution = None self._measured = None self._stationname = None + self._unique_id = self.uid(coordinates) + + # All continuous sensors should be forced to be updated + self._force_update = self.type != SYMBOL and \ + not self.type.startswith(CONDITION) if self.type.startswith(PRECIPITATION_FORECAST): self._timeframe = None + def uid(self, coordinates): + """Generate a unique id using coordinates and sensor type.""" + # The combination of the location, name and sensor type is unique + return "%2.6f%2.6f%s" % (coordinates[CONF_LATITUDE], + coordinates[CONF_LONGITUDE], + self.type) + def load_data(self, data): """Load the sensor with relevant data.""" # Find sensor @@ -198,6 +211,11 @@ class BrSensor(Entity): PRECIPITATION_FORECAST, STATIONNAME, TIMEFRAME) + # Check if we have a new measurement, + # otherwise we do not have to update the sensor + if self._measured == data.get(MEASURED): + return False + self._attribution = data.get(ATTRIBUTION) self._stationname = data.get(STATIONNAME) self._measured = data.get(MEASURED) @@ -246,18 +264,12 @@ class BrSensor(Entity): return False else: try: - new_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) + return True except IndexError: _LOGGER.warning("No forecast for fcday=%s...", fcday) return False - if new_state != self._state: - self._state = new_state - return True - return False - - return False - if self.type == SYMBOL or self.type.startswith(CONDITION): # update weather symbol & status text condition = data.get(CONDITION, None) @@ -286,27 +298,26 @@ class BrSensor(Entity): if self.type.startswith(PRECIPITATION_FORECAST): # update nested precipitation forecast sensors nested = data.get(PRECIPITATION_FORECAST) - new_state = nested.get(self.type[len(PRECIPITATION_FORECAST)+1:]) self._timeframe = nested.get(TIMEFRAME) # pylint: disable=protected-access - if new_state != self._state: - self._state = new_state - return True - return False + self._state = nested.get(self.type[len(PRECIPITATION_FORECAST)+1:]) + return True # update all other sensors - new_state = data.get(self.type) # pylint: disable=protected-access - if new_state != self._state: - self._state = new_state - return True - return False + self._state = data.get(self.type) + return True @property def attribution(self): """Return the attribution.""" return self._attribution + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + @property def name(self): """Return the name of the sensor.""" @@ -360,6 +371,11 @@ class BrSensor(Entity): """Return possible sensor specific icon.""" return SENSOR_TYPES[self.type][2] + @property + def force_update(self): + """Return true for continuous sensors, false for discrete sensors.""" + return self._force_update + class BrData(object): """Get the latest data and updates the states.""" diff --git a/homeassistant/components/sensor/canary.py b/homeassistant/components/sensor/canary.py index ded8f36203e..51fe1d4dd7a 100644 --- a/homeassistant/components/sensor/canary.py +++ b/homeassistant/components/sensor/canary.py @@ -8,6 +8,7 @@ https://home-assistant.io/components/sensor.canary/ from homeassistant.components.canary import DATA_CANARY from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level DEPENDENCIES = ['canary'] @@ -17,9 +18,11 @@ ATTR_AIR_QUALITY = "air_quality" # Sensor types are defined like so: # sensor type name, unit_of_measurement, icon SENSOR_TYPES = [ - ["temperature", TEMP_CELSIUS, "mdi:thermometer"], - ["humidity", "%", "mdi:water-percent"], - ["air_quality", None, "mdi:weather-windy"], + ["temperature", TEMP_CELSIUS, "mdi:thermometer", ["Canary"]], + ["humidity", "%", "mdi:water-percent", ["Canary"]], + ["air_quality", None, "mdi:weather-windy", ["Canary"]], + ["wifi", "dBm", "mdi:wifi", ["Canary Flex"]], + ["battery", "%", "mdi:battery-50", ["Canary Flex"]], ] STATE_AIR_QUALITY_NORMAL = "normal" @@ -35,9 +38,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for location in data.locations: for device in location.devices: if device.is_online: + device_type = device.device_type for sensor_type in SENSOR_TYPES: - devices.append(CanarySensor(data, sensor_type, location, - device)) + if device_type.get("name") in sensor_type[3]: + devices.append(CanarySensor(data, sensor_type, + location, device)) add_devices(devices, True) @@ -80,6 +85,9 @@ class CanarySensor(Entity): @property def icon(self): """Icon for the sensor.""" + if self.state is not None and self._sensor_type[0] == "battery": + return icon_for_battery_level(battery_level=self.state) + return self._sensor_type[2] @property @@ -113,6 +121,10 @@ class CanarySensor(Entity): canary_sensor_type = SensorType.TEMPERATURE elif self._sensor_type[0] == "humidity": canary_sensor_type = SensorType.HUMIDITY + elif self._sensor_type[0] == "wifi": + canary_sensor_type = SensorType.WIFI + elif self._sensor_type[0] == "battery": + canary_sensor_type = SensorType.BATTERY value = self._data.get_reading(self._device_id, canary_sensor_type) diff --git a/homeassistant/components/sensor/citybikes.py b/homeassistant/components/sensor/citybikes.py index b7635f729e2..a8bc441b722 100644 --- a/homeassistant/components/sensor/citybikes.py +++ b/homeassistant/components/sensor/citybikes.py @@ -4,32 +4,31 @@ Sensor for the CityBikes data. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.citybikes/ """ -import logging -from datetime import timedelta - import asyncio +from datetime import timedelta +import logging + import aiohttp import async_timeout import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT +from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, - ATTR_ATTRIBUTION, ATTR_LOCATION, ATTR_LATITUDE, ATTR_LONGITUDE, - STATE_UNKNOWN, LENGTH_METERS, LENGTH_FEET, ATTR_ID) + ATTR_ATTRIBUTION, ATTR_ID, ATTR_LATITUDE, ATTR_LOCATION, ATTR_LONGITUDE, + ATTR_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS, + LENGTH_FEET, LENGTH_METERS) from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import location, distance +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity, async_generate_entity_id +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import distance, location _LOGGER = logging.getLogger(__name__) ATTR_EMPTY_SLOTS = 'empty_slots' ATTR_EXTRA = 'extra' ATTR_FREE_BIKES = 'free_bikes' -ATTR_NAME = 'name' ATTR_NETWORK = 'network' ATTR_NETWORKS_LIST = 'networks' ATTR_STATIONS_LIST = 'stations' @@ -151,8 +150,7 @@ def async_setup_platform(hass, config, async_add_devices, network = CityBikesNetwork(hass, network_id) hass.data[PLATFORM][MONITORED_NETWORKS][network_id] = network hass.async_add_job(network.async_refresh) - async_track_time_interval(hass, network.async_refresh, - SCAN_INTERVAL) + async_track_time_interval(hass, network.async_refresh, SCAN_INTERVAL) else: network = hass.data[PLATFORM][MONITORED_NETWORKS][network_id] @@ -160,14 +158,14 @@ def async_setup_platform(hass, config, async_add_devices, devices = [] for station in network.stations: - dist = location.distance(latitude, longitude, - station[ATTR_LATITUDE], - station[ATTR_LONGITUDE]) + dist = location.distance( + latitude, longitude, station[ATTR_LATITUDE], + station[ATTR_LONGITUDE]) station_id = station[ATTR_ID] station_uid = str(station.get(ATTR_EXTRA, {}).get(ATTR_UID, '')) - if radius > dist or stations_list.intersection((station_id, - station_uid)): + if radius > dist or stations_list.intersection( + (station_id, station_uid)): devices.append(CityBikesStation(hass, network, station_id, name)) async_add_devices(devices, True) @@ -199,8 +197,8 @@ class CityBikesNetwork: for network in networks_list[1:]: network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE] network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE] - dist = location.distance(latitude, longitude, - network_latitude, network_longitude) + dist = location.distance( + latitude, longitude, network_latitude, network_longitude) if dist < minimum_dist: minimum_dist = dist result = network[ATTR_ID] @@ -246,13 +244,13 @@ class CityBikesStation(Entity): uid = "_".join([network.network_id, base_name, station_id]) else: uid = "_".join([network.network_id, station_id]) - self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, uid, - hass=hass) + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, uid, hass=hass) @property def state(self): """Return the state of the sensor.""" - return self._station_data.get(ATTR_FREE_BIKES, STATE_UNKNOWN) + return self._station_data.get(ATTR_FREE_BIKES, None) @property def name(self): diff --git a/homeassistant/components/sensor/coinmarketcap.py b/homeassistant/components/sensor/coinmarketcap.py index f8ada07eec6..f4b666f1e5c 100644 --- a/homeassistant/components/sensor/coinmarketcap.py +++ b/homeassistant/components/sensor/coinmarketcap.py @@ -13,65 +13,78 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_CURRENCY, CONF_DISPLAY_CURRENCY) + ATTR_ATTRIBUTION, CONF_DISPLAY_CURRENCY) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['coinmarketcap==4.2.1'] +REQUIREMENTS = ['coinmarketcap==5.0.3'] _LOGGER = logging.getLogger(__name__) -ATTR_24H_VOLUME = '24h_volume' +ATTR_VOLUME_24H = 'volume_24h' ATTR_AVAILABLE_SUPPLY = 'available_supply' +ATTR_CIRCULATING_SUPPLY = 'circulating_supply' ATTR_MARKET_CAP = 'market_cap' -ATTR_NAME = 'name' ATTR_PERCENT_CHANGE_24H = 'percent_change_24h' ATTR_PERCENT_CHANGE_7D = 'percent_change_7d' ATTR_PERCENT_CHANGE_1H = 'percent_change_1h' ATTR_PRICE = 'price' +ATTR_RANK = 'rank' ATTR_SYMBOL = 'symbol' ATTR_TOTAL_SUPPLY = 'total_supply' CONF_ATTRIBUTION = "Data provided by CoinMarketCap" +CONF_CURRENCY_ID = 'currency_id' +CONF_DISPLAY_CURRENCY_DECIMALS = 'display_currency_decimals' -DEFAULT_CURRENCY = 'bitcoin' +DEFAULT_CURRENCY_ID = 1 DEFAULT_DISPLAY_CURRENCY = 'USD' +DEFAULT_DISPLAY_CURRENCY_DECIMALS = 2 ICON = 'mdi:currency-usd' SCAN_INTERVAL = timedelta(minutes=15) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): cv.string, + vol.Optional(CONF_CURRENCY_ID, default=DEFAULT_CURRENCY_ID): + cv.positive_int, vol.Optional(CONF_DISPLAY_CURRENCY, default=DEFAULT_DISPLAY_CURRENCY): cv.string, + vol.Optional(CONF_DISPLAY_CURRENCY_DECIMALS, + default=DEFAULT_DISPLAY_CURRENCY_DECIMALS): + vol.All(vol.Coerce(int), vol.Range(min=1)), }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the CoinMarketCap sensor.""" - currency = config.get(CONF_CURRENCY) - display_currency = config.get(CONF_DISPLAY_CURRENCY).lower() + currency_id = config.get(CONF_CURRENCY_ID) + display_currency = config.get(CONF_DISPLAY_CURRENCY).upper() + display_currency_decimals = config.get(CONF_DISPLAY_CURRENCY_DECIMALS) try: - CoinMarketCapData(currency, display_currency).update() + CoinMarketCapData(currency_id, display_currency).update() except HTTPError: - _LOGGER.warning("Currency %s or display currency %s is not available. " - "Using bitcoin and USD.", currency, display_currency) - currency = DEFAULT_CURRENCY + _LOGGER.warning("Currency ID %s or display currency %s " + "is not available. Using 1 (bitcoin) " + "and USD.", currency_id, display_currency) + currency_id = DEFAULT_CURRENCY_ID display_currency = DEFAULT_DISPLAY_CURRENCY add_devices([CoinMarketCapSensor( - CoinMarketCapData(currency, display_currency))], True) + CoinMarketCapData( + currency_id, display_currency), display_currency_decimals)], True) class CoinMarketCapSensor(Entity): """Representation of a CoinMarketCap sensor.""" - def __init__(self, data): + def __init__(self, data, display_currency_decimals): """Initialize the sensor.""" self.data = data + self.display_currency_decimals = display_currency_decimals self._ticker = None - self._unit_of_measurement = self.data.display_currency.upper() + self._unit_of_measurement = self.data.display_currency @property def name(self): @@ -81,8 +94,9 @@ class CoinMarketCapSensor(Entity): @property def state(self): """Return the state of the sensor.""" - return round(float(self._ticker.get( - 'price_{}'.format(self.data.display_currency))), 2) + return round(float( + self._ticker.get('quotes').get(self.data.display_currency) + .get('price')), self.display_currency_decimals) @property def unit_of_measurement(self): @@ -98,15 +112,24 @@ class CoinMarketCapSensor(Entity): def device_state_attributes(self): """Return the state attributes of the sensor.""" return { - ATTR_24H_VOLUME: self._ticker.get( - '24h_volume_{}'.format(self.data.display_currency)), + ATTR_VOLUME_24H: + self._ticker.get('quotes').get(self.data.display_currency) + .get('volume_24h'), ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_AVAILABLE_SUPPLY: self._ticker.get('available_supply'), - ATTR_MARKET_CAP: self._ticker.get( - 'market_cap_{}'.format(self.data.display_currency)), - ATTR_PERCENT_CHANGE_24H: self._ticker.get('percent_change_24h'), - ATTR_PERCENT_CHANGE_7D: self._ticker.get('percent_change_7d'), - ATTR_PERCENT_CHANGE_1H: self._ticker.get('percent_change_1h'), + ATTR_CIRCULATING_SUPPLY: self._ticker.get('circulating_supply'), + ATTR_MARKET_CAP: + self._ticker.get('quotes').get(self.data.display_currency) + .get('market_cap'), + ATTR_PERCENT_CHANGE_24H: + self._ticker.get('quotes').get(self.data.display_currency) + .get('percent_change_24h'), + ATTR_PERCENT_CHANGE_7D: + self._ticker.get('quotes').get(self.data.display_currency) + .get('percent_change_7d'), + ATTR_PERCENT_CHANGE_1H: + self._ticker.get('quotes').get(self.data.display_currency) + .get('percent_change_1h'), + ATTR_RANK: self._ticker.get('rank'), ATTR_SYMBOL: self._ticker.get('symbol'), ATTR_TOTAL_SUPPLY: self._ticker.get('total_supply'), } @@ -114,22 +137,20 @@ class CoinMarketCapSensor(Entity): def update(self): """Get the latest data and updates the states.""" self.data.update() - self._ticker = self.data.ticker[0] + self._ticker = self.data.ticker.get('data') class CoinMarketCapData(object): """Get the latest data and update the states.""" - def __init__(self, currency, display_currency): + def __init__(self, currency_id, display_currency): """Initialize the data object.""" - self.currency = currency + self.currency_id = currency_id self.display_currency = display_currency self.ticker = None def update(self): - """Get the latest data from blockchain.info.""" + """Get the latest data from coinmarketcap.com.""" from coinmarketcap import Market self.ticker = Market().ticker( - self.currency, - limit=1, - convert=self.display_currency) + self.currency_id, convert=self.display_currency) diff --git a/homeassistant/components/sensor/comed_hourly_pricing.py b/homeassistant/components/sensor/comed_hourly_pricing.py index 01e9f443e0e..c0c477ade0b 100644 --- a/homeassistant/components/sensor/comed_hourly_pricing.py +++ b/homeassistant/components/sensor/comed_hourly_pricing.py @@ -4,19 +4,21 @@ Support for ComEd Hourly Pricing data. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.comed_hourly_pricing/ """ -from datetime import timedelta -import logging import asyncio +from datetime import timedelta import json -import async_timeout +import logging + import aiohttp +import async_timeout import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN -from homeassistant.helpers.entity import Entity +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET, STATE_UNKNOWN) from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://hourlypricing.comed.com/api' @@ -27,8 +29,6 @@ CONF_ATTRIBUTION = "Data provided by ComEd Hourly Pricing service" CONF_CURRENT_HOUR_AVERAGE = 'current_hour_average' CONF_FIVE_MINUTE = 'five_minute' CONF_MONITORED_FEEDS = 'monitored_feeds' -CONF_NAME = 'name' -CONF_OFFSET = 'offset' CONF_SENSOR_TYPE = 'type' SENSOR_TYPES = { @@ -40,12 +40,12 @@ TYPES_SCHEMA = vol.In(SENSOR_TYPES) SENSORS_SCHEMA = vol.Schema({ vol.Required(CONF_SENSOR_TYPE): TYPES_SCHEMA, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_OFFSET, default=0.0): vol.Coerce(float), - vol.Optional(CONF_NAME): cv.string }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_MONITORED_FEEDS): [SENSORS_SCHEMA] + vol.Required(CONF_MONITORED_FEEDS): [SENSORS_SCHEMA], }) diff --git a/homeassistant/components/sensor/cpuspeed.py b/homeassistant/components/sensor/cpuspeed.py index 25b7bba506c..c39ae43aef0 100644 --- a/homeassistant/components/sensor/cpuspeed.py +++ b/homeassistant/components/sensor/cpuspeed.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['py-cpuinfo==3.3.0'] +REQUIREMENTS = ['py-cpuinfo==4.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/crimereports.py b/homeassistant/components/sensor/crimereports.py index aecfca60bf1..a2d7315a314 100644 --- a/homeassistant/components/sensor/crimereports.py +++ b/homeassistant/components/sensor/crimereports.py @@ -89,6 +89,7 @@ class CrimeReportsSensor(Entity): return self._attributes def _incident_event(self, incident): + """Fire if an event occurs.""" data = { 'type': incident.get('type'), 'description': incident.get('friendly_description'), diff --git a/homeassistant/components/sensor/darksky.py b/homeassistant/components/sensor/darksky.py index e224feb7db7..e75f36d59f7 100644 --- a/homeassistant/components/sensor/darksky.py +++ b/homeassistant/components/sensor/darksky.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-forecastio==1.3.5'] +REQUIREMENTS = ['python-forecastio==1.4.0'] _LOGGER = logging.getLogger(__name__) @@ -27,9 +27,17 @@ CONF_ATTRIBUTION = "Powered by Dark Sky" CONF_UNITS = 'units' CONF_UPDATE_INTERVAL = 'update_interval' CONF_FORECAST = 'forecast' +CONF_LANGUAGE = 'language' + +DEFAULT_LANGUAGE = 'en' DEFAULT_NAME = 'Dark Sky' +DEPRECATED_SENSOR_TYPES = {'apparent_temperature_max', + 'apparent_temperature_min', + 'temperature_max', + 'temperature_min'} + # Sensor types are defined like so: # Name, si unit, us unit, ca unit, uk unit, uk2 unit SENSOR_TYPES = { @@ -51,7 +59,8 @@ SENSOR_TYPES = { 'mdi:weather-pouring', ['currently', 'minutely', 'hourly', 'daily']], 'precip_intensity': ['Precip Intensity', - 'mm', 'in', 'mm', 'mm', 'mm', 'mdi:weather-rainy', + 'mm/h', 'in', 'mm/h', 'mm/h', 'mm/h', + 'mdi:weather-rainy', ['currently', 'minutely', 'hourly', 'daily']], 'precip_probability': ['Precip Probability', '%', '%', '%', '%', '%', 'mdi:water-percent', @@ -86,18 +95,31 @@ SENSOR_TYPES = { '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['currently', 'hourly', 'daily']], + 'apparent_temperature_high': ["Daytime High Apparent Temperature", + '°C', '°F', '°C', '°C', '°C', + 'mdi:thermometer', ['daily']], 'apparent_temperature_min': ['Daily Low Apparent Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['currently', 'hourly', 'daily']], + 'apparent_temperature_low': ['Overnight Low Apparent Temperature', + '°C', '°F', '°C', '°C', '°C', + 'mdi:thermometer', ['daily']], 'temperature_max': ['Daily High Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', - ['currently', 'hourly', 'daily']], + ['daily']], + 'temperature_high': ['Daytime High Temperature', + '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', + ['daily']], 'temperature_min': ['Daily Low Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', - ['currently', 'hourly', 'daily']], + ['daily']], + 'temperature_low': ['Overnight Low Temperature', + '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', + ['daily']], 'precip_intensity_max': ['Daily Max Precip Intensity', - 'mm', 'in', 'mm', 'mm', 'mm', 'mdi:thermometer', + 'mm/h', 'in', 'mm/h', 'mm/h', 'mm/h', + 'mdi:thermometer', ['currently', 'hourly', 'daily']], 'uv_index': ['UV Index', UNIT_UV_INDEX, UNIT_UV_INDEX, UNIT_UV_INDEX, @@ -118,6 +140,16 @@ CONDITION_PICTURES = { 'partly-cloudy-night': '/static/images/darksky/weather-cloudy.svg', } +# Language Supported Codes +LANGUAGE_CODES = [ + 'ar', 'az', 'be', 'bg', 'bs', 'ca', + 'cs', 'da', 'de', 'el', 'en', 'es', + 'et', 'fi', 'fr', 'hr', 'hu', 'id', + 'is', 'it', 'ja', 'ka', 'kw', 'nb', + 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', + 'sl', 'sr', 'sv', 'tet', 'tr', 'uk', + 'x-pig-latin', 'zh', 'zh-tw', +] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MONITORED_CONDITIONS): @@ -125,11 +157,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNITS): vol.In(['auto', 'si', 'us', 'ca', 'uk', 'uk2']), + vol.Optional(CONF_LANGUAGE, + default=DEFAULT_LANGUAGE): vol.In(LANGUAGE_CODES), vol.Inclusive(CONF_LATITUDE, 'coordinates', 'Latitude and longitude must exist together'): cv.latitude, vol.Inclusive(CONF_LONGITUDE, 'coordinates', 'Latitude and longitude must exist together'): cv.longitude, - vol.Optional(CONF_UPDATE_INTERVAL, default=timedelta(seconds=120)): ( + vol.Optional(CONF_UPDATE_INTERVAL, default=timedelta(seconds=300)): ( vol.All(cv.time_period, cv.positive_timedelta)), vol.Optional(CONF_FORECAST): vol.All(cv.ensure_list, [vol.Range(min=1, max=7)]), @@ -140,6 +174,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Dark Sky sensor.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + language = config.get(CONF_LANGUAGE) if CONF_UNITS in config: units = config[CONF_UNITS] @@ -153,6 +188,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): latitude=latitude, longitude=longitude, units=units, + language=language, interval=config.get(CONF_UPDATE_INTERVAL)) forecast_data.update() forecast_data.update_currently() @@ -166,6 +202,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): forecast = config.get(CONF_FORECAST) sensors = [] for variable in config[CONF_MONITORED_CONDITIONS]: + if variable in DEPRECATED_SENSOR_TYPES: + _LOGGER.warning("Monitored condition %s is deprecated.", + variable) sensors.append(DarkSkySensor(forecast_data, variable, name)) if forecast is not None and 'daily' in SENSOR_TYPES[variable][7]: for forecast_day in forecast: @@ -269,9 +308,13 @@ class DarkSkySensor(Entity): elif self.forecast_day > 0 or ( self.type in ['daily_summary', 'temperature_min', + 'temperature_low', 'temperature_max', + 'temperature_high', 'apparent_temperature_min', + 'apparent_temperature_low', 'apparent_temperature_max', + 'apparent_temperature_high', 'precip_intensity_max', 'precip_accumulation']): self.forecast_data.update_daily() @@ -332,12 +375,14 @@ def convert_to_camel(data): class DarkSkyData(object): """Get the latest data from Darksky.""" - def __init__(self, api_key, latitude, longitude, units, interval): + def __init__(self, api_key, latitude, longitude, units, language, + interval): """Initialize the data object.""" self._api_key = api_key self.latitude = latitude self.longitude = longitude self.units = units + self.language = language self.data = None self.unit_system = None @@ -359,7 +404,8 @@ class DarkSkyData(object): try: self.data = forecastio.load_forecast( - self._api_key, self.latitude, self.longitude, units=self.units) + self._api_key, self.latitude, self.longitude, units=self.units, + lang=self.language) except (ConnectError, HTTPError, Timeout, ValueError) as error: _LOGGER.error("Unable to connect to Dark Sky. %s", error) self.data = None diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index a3c2aa683dc..0db06622ad8 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -5,38 +5,50 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/sensor.deconz/ """ from homeassistant.components.deconz import ( - DOMAIN as DATA_DECONZ, DATA_DECONZ_ID) -from homeassistant.const import ATTR_BATTERY_LEVEL, CONF_EVENT, CONF_ID -from homeassistant.core import EventOrigin, callback + CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, + DATA_DECONZ_UNSUB) +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify DEPENDENCIES = ['deconz'] +ATTR_CURRENT = 'current' +ATTR_DAYLIGHT = 'daylight' ATTR_EVENT_ID = 'event_id' async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Old way of setting up deCONZ sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): """Set up the deCONZ sensors.""" - if discovery_info is None: - return + @callback + def async_add_sensor(sensors): + """Add sensors from deCONZ.""" + from pydeconz.sensor import DECONZ_SENSOR, SWITCH as DECONZ_REMOTE + entities = [] + allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) + for sensor in sensors: + if sensor.type in DECONZ_SENSOR and \ + not (not allow_clip_sensor and sensor.type.startswith('CLIP')): + if sensor.type in DECONZ_REMOTE: + if sensor.battery: + entities.append(DeconzBattery(sensor)) + else: + entities.append(DeconzSensor(sensor)) + async_add_devices(entities, True) - from pydeconz.sensor import DECONZ_SENSOR, SWITCH as DECONZ_REMOTE - sensors = hass.data[DATA_DECONZ].sensors - entities = [] + hass.data[DATA_DECONZ_UNSUB].append( + async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) - for key in sorted(sensors.keys(), key=int): - sensor = sensors[key] - if sensor and sensor.type in DECONZ_SENSOR: - if sensor.type in DECONZ_REMOTE: - DeconzEvent(hass, sensor) - if sensor.battery: - entities.append(DeconzBattery(sensor)) - else: - entities.append(DeconzSensor(sensor)) - async_add_devices(entities, True) + async_add_sensor(hass.data[DATA_DECONZ].sensors.values()) class DeconzSensor(Entity): @@ -106,9 +118,17 @@ class DeconzSensor(Entity): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - attr = { - ATTR_BATTERY_LEVEL: self._sensor.battery, - } + from pydeconz.sensor import LIGHTLEVEL + attr = {} + if self._sensor.battery: + attr[ATTR_BATTERY_LEVEL] = self._sensor.battery + if self._sensor.type in LIGHTLEVEL and self._sensor.dark is not None: + attr['dark'] = self._sensor.dark + if self.unit_of_measurement == 'Watts': + attr[ATTR_CURRENT] = self._sensor.current + attr[ATTR_VOLTAGE] = self._sensor.voltage + if self._sensor.sensor_class == 'daylight': + attr[ATTR_DAYLIGHT] = self._sensor.daylight return attr @@ -119,7 +139,6 @@ class DeconzBattery(Entity): """Register dispatcher callback for update of battery state.""" self._device = device self._name = '{} {}'.format(self._device.name, 'Battery Level') - self._device_class = 'battery' self._unit_of_measurement = "%" async def async_added_to_hass(self): @@ -151,12 +170,7 @@ class DeconzBattery(Entity): @property def device_class(self): """Return the class of the sensor.""" - return self._device_class - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return icon_for_battery_level(int(self.state)) + return DEVICE_CLASS_BATTERY @property def unit_of_measurement(self): @@ -175,26 +189,3 @@ class DeconzBattery(Entity): ATTR_EVENT_ID: slugify(self._device.name), } return attr - - -class DeconzEvent(object): - """When you want signals instead of entities. - - Stateless sensors such as remotes are expected to generate an event - instead of a sensor entity in hass. - """ - - def __init__(self, hass, device): - """Register callback that will be used for signals.""" - self._hass = hass - self._device = device - self._device.register_async_callback(self.async_update_callback) - self._event = 'deconz_{}'.format(CONF_EVENT) - self._id = slugify(self._device.name) - - @callback - def async_update_callback(self, reason): - """Fire the event if reason is that state is updated.""" - if reason['state']: - data = {CONF_ID: self._id, CONF_EVENT: self._device.state} - self._hass.bus.async_fire(self._event, data, EventOrigin.remote) diff --git a/homeassistant/components/sensor/deluge.py b/homeassistant/components/sensor/deluge.py index f4793867d4c..8acbda74d7d 100644 --- a/homeassistant/components/sensor/deluge.py +++ b/homeassistant/components/sensor/deluge.py @@ -14,8 +14,9 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT, CONF_MONITORED_VARIABLES, STATE_IDLE) from homeassistant.helpers.entity import Entity +from homeassistant.exceptions import PlatformNotReady -REQUIREMENTS = ['deluge-client==1.0.5'] +REQUIREMENTS = ['deluge-client==1.4.0'] _LOGGER = logging.getLogger(__name__) _THROTTLED_REFRESH = None @@ -24,7 +25,6 @@ DEFAULT_NAME = 'Deluge' DEFAULT_PORT = 58846 DHT_UPLOAD = 1000 DHT_DOWNLOAD = 1000 - SENSOR_TYPES = { 'current_status': ['Status', None], 'download_speed': ['Down Speed', 'kB/s'], @@ -58,8 +58,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): deluge_api.connect() except ConnectionRefusedError: _LOGGER.error("Connection to Deluge Daemon failed") - return - + raise PlatformNotReady dev = [] for variable in config[CONF_MONITORED_VARIABLES]: dev.append(DelugeSensor(variable, deluge_api, name)) @@ -79,6 +78,7 @@ class DelugeSensor(Entity): self._state = None self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self.data = None + self._available = False @property def name(self): @@ -90,6 +90,11 @@ class DelugeSensor(Entity): """Return the state of the sensor.""" return self._state + @property + def available(self): + """Return true if device is available.""" + return self._available + @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" @@ -97,9 +102,17 @@ class DelugeSensor(Entity): def update(self): """Get the latest data from Deluge and updates the state.""" - self.data = self.client.call('core.get_session_status', - ['upload_rate', 'download_rate', - 'dht_upload_rate', 'dht_download_rate']) + from deluge_client import FailedToReconnectException + try: + self.data = self.client.call('core.get_session_status', + ['upload_rate', 'download_rate', + 'dht_upload_rate', + 'dht_download_rate']) + self._available = True + except FailedToReconnectException: + _LOGGER.error("Connection to Deluge Daemon Lost") + self._available = False + return upload = self.data[b'upload_rate'] - self.data[b'dht_upload_rate'] download = self.data[b'download_rate'] - self.data[ diff --git a/homeassistant/components/sensor/demo.py b/homeassistant/components/sensor/demo.py index ba7c93203df..325d3e0ae58 100644 --- a/homeassistant/components/sensor/demo.py +++ b/homeassistant/components/sensor/demo.py @@ -4,7 +4,9 @@ Demo platform that has a couple of fake sensors. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ -from homeassistant.const import ATTR_BATTERY_LEVEL, TEMP_CELSIUS +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, TEMP_CELSIUS, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE) from homeassistant.helpers.entity import Entity @@ -12,18 +14,21 @@ from homeassistant.helpers.entity import Entity def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo sensors.""" add_devices([ - DemoSensor('Outside Temperature', 15.6, TEMP_CELSIUS, 12), - DemoSensor('Outside Humidity', 54, '%', None), + DemoSensor('Outside Temperature', 15.6, DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, 12), + DemoSensor('Outside Humidity', 54, DEVICE_CLASS_HUMIDITY, '%', None), ]) class DemoSensor(Entity): """Representation of a Demo sensor.""" - def __init__(self, name, state, unit_of_measurement, battery): + def __init__(self, name, state, device_class, + unit_of_measurement, battery): """Initialize the sensor.""" self._name = name self._state = state + self._device_class = device_class self._unit_of_measurement = unit_of_measurement self._battery = battery @@ -32,6 +37,11 @@ class DemoSensor(Entity): """No polling needed for a demo sensor.""" return False + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + @property def name(self): """Return the name of the sensor.""" diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index cea29d437ae..d7982f1c9db 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -31,6 +31,8 @@ DOMAIN = 'dsmr' ICON_GAS = 'mdi:fire' ICON_POWER = 'mdi:flash' +ICON_POWER_FAILURE = 'mdi:flash-off' +ICON_SWELL_SAG = 'mdi:pulse' # Smart meter sends telegram every 10 seconds MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) @@ -61,13 +63,86 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # Define list of name,obis mappings to generate entities obis_mapping = [ - ['Power Consumption', obis_ref.CURRENT_ELECTRICITY_USAGE], - ['Power Production', obis_ref.CURRENT_ELECTRICITY_DELIVERY], - ['Power Tariff', obis_ref.ELECTRICITY_ACTIVE_TARIFF], - ['Power Consumption (low)', obis_ref.ELECTRICITY_USED_TARIFF_1], - ['Power Consumption (normal)', obis_ref.ELECTRICITY_USED_TARIFF_2], - ['Power Production (low)', obis_ref.ELECTRICITY_DELIVERED_TARIFF_1], - ['Power Production (normal)', obis_ref.ELECTRICITY_DELIVERED_TARIFF_2], + [ + 'Power Consumption', + obis_ref.CURRENT_ELECTRICITY_USAGE + ], + [ + 'Power Production', + obis_ref.CURRENT_ELECTRICITY_DELIVERY + ], + [ + 'Power Tariff', + obis_ref.ELECTRICITY_ACTIVE_TARIFF + ], + [ + 'Power Consumption (low)', + obis_ref.ELECTRICITY_USED_TARIFF_1 + ], + [ + 'Power Consumption (normal)', + obis_ref.ELECTRICITY_USED_TARIFF_2 + ], + [ + 'Power Production (low)', + obis_ref.ELECTRICITY_DELIVERED_TARIFF_1 + ], + [ + 'Power Production (normal)', + obis_ref.ELECTRICITY_DELIVERED_TARIFF_2 + ], + [ + 'Power Consumption Phase L1', + obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE + ], + [ + 'Power Consumption Phase L2', + obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE + ], + [ + 'Power Consumption Phase L3', + obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE + ], + [ + 'Power Production Phase L1', + obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE + ], + [ + 'Power Production Phase L2', + obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE + ], + [ + 'Power Production Phase L3', + obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE + ], + [ + 'Long Power Failure Count', + obis_ref.LONG_POWER_FAILURE_COUNT + ], + [ + 'Voltage Sags Phase L1', + obis_ref.VOLTAGE_SAG_L1_COUNT + ], + [ + 'Voltage Sags Phase L2', + obis_ref.VOLTAGE_SAG_L2_COUNT + ], + [ + 'Voltage Sags Phase L3', + obis_ref.VOLTAGE_SAG_L3_COUNT + ], + [ + 'Voltage Swells Phase L1', + obis_ref.VOLTAGE_SWELL_L1_COUNT + ], + [ + 'Voltage Swells Phase L2', + obis_ref.VOLTAGE_SWELL_L2_COUNT + ], + [ + 'Voltage Swells Phase L3', + obis_ref.VOLTAGE_SWELL_L3_COUNT + ], ] # Generate device entities @@ -174,6 +249,10 @@ class DSMREntity(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" + if 'Sags' in self._name or 'Swells' in self.name: + return ICON_SWELL_SAG + if 'Failure' in self._name: + return ICON_POWER_FAILURE if 'Power' in self._name: return ICON_POWER elif 'Gas' in self._name: diff --git a/homeassistant/components/sensor/ebox.py b/homeassistant/components/sensor/ebox.py index eee959fceba..d7b867081a3 100644 --- a/homeassistant/components/sensor/ebox.py +++ b/homeassistant/components/sensor/ebox.py @@ -9,7 +9,6 @@ https://home-assistant.io/components/sensor.ebox/ import logging from datetime import timedelta -import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -18,8 +17,11 @@ from homeassistant.const import ( CONF_USERNAME, CONF_PASSWORD, CONF_NAME, CONF_MONITORED_VARIABLES) from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +from homeassistant.exceptions import PlatformNotReady -REQUIREMENTS = ['pyebox==0.1.0'] + +REQUIREMENTS = ['pyebox==1.1.4'] _LOGGER = logging.getLogger(__name__) @@ -31,7 +33,8 @@ PERCENT = '%' # type: str DEFAULT_NAME = 'EBox' REQUESTS_TIMEOUT = 15 -SCAN_INTERVAL = timedelta(minutes=5) +SCAN_INTERVAL = timedelta(minutes=15) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) SENSOR_TYPES = { 'usage': ['Usage', PERCENT, 'mdi:percent'], @@ -61,25 +64,29 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the EBox sensor.""" username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - try: - ebox_data = EBoxData(username, password) - ebox_data.update() - except requests.exceptions.HTTPError as error: - _LOGGER.error("Failed login: %s", error) - return False + httpsession = hass.helpers.aiohttp_client.async_get_clientsession() + ebox_data = EBoxData(username, password, httpsession) name = config.get(CONF_NAME) + from pyebox.client import PyEboxError + try: + await ebox_data.async_update() + except PyEboxError as exp: + _LOGGER.error("Failed login: %s", exp) + raise PlatformNotReady + sensors = [] for variable in config[CONF_MONITORED_VARIABLES]: sensors.append(EBoxSensor(ebox_data, variable, name)) - add_devices(sensors, True) + async_add_devices(sensors, True) class EBoxSensor(Entity): @@ -115,9 +122,9 @@ class EBoxSensor(Entity): """Icon to use in the frontend, if any.""" return self._icon - def update(self): + async def async_update(self): """Get the latest data from EBox and update the state.""" - self.ebox_data.update() + await self.ebox_data.async_update() if self.type in self.ebox_data.data: self._state = round(self.ebox_data.data[self.type], 2) @@ -125,18 +132,21 @@ class EBoxSensor(Entity): class EBoxData(object): """Get data from Ebox.""" - def __init__(self, username, password): + def __init__(self, username, password, httpsession): """Initialize the data object.""" from pyebox import EboxClient - self.client = EboxClient(username, password, REQUESTS_TIMEOUT) + self.client = EboxClient(username, password, + REQUESTS_TIMEOUT, httpsession) self.data = {} - def update(self): + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): """Get the latest data from Ebox.""" from pyebox.client import PyEboxError try: - self.client.fetch_data() + await self.client.fetch_data() except PyEboxError as exp: _LOGGER.error("Error on receive last EBox data: %s", exp) return + # Update data self.data = self.client.get_data() diff --git a/homeassistant/components/sensor/ecobee.py b/homeassistant/components/sensor/ecobee.py index dad770d5bab..a478f964f5a 100644 --- a/homeassistant/components/sensor/ecobee.py +++ b/homeassistant/components/sensor/ecobee.py @@ -5,7 +5,8 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.ecobee/ """ from homeassistant.components import ecobee -from homeassistant.const import TEMP_FAHRENHEIT +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_FAHRENHEIT) from homeassistant.helpers.entity import Entity DEPENDENCIES = ['ecobee'] @@ -52,6 +53,13 @@ class EcobeeSensor(Entity): """Return the name of the Ecobee sensor.""" return self._name + @property + def device_class(self): + """Return the device class of the sensor.""" + if self.type in (DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE): + return self.type + return None + @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/sensor/eddystone_temperature.py b/homeassistant/components/sensor/eddystone_temperature.py index fb5fa2c1fba..2c8ad4781d0 100644 --- a/homeassistant/components/sensor/eddystone_temperature.py +++ b/homeassistant/components/sensor/eddystone_temperature.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_NAME, TEMP_CELSIUS, STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['beacontools[scan]==1.2.1'] +REQUIREMENTS = ['beacontools[scan]==1.2.3', 'construct==2.9.41'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/eliqonline.py b/homeassistant/components/sensor/eliqonline.py index 3e736ed719f..6405c707536 100644 --- a/homeassistant/components/sensor/eliqonline.py +++ b/homeassistant/components/sensor/eliqonline.py @@ -14,7 +14,7 @@ from homeassistant.const import (CONF_ACCESS_TOKEN, CONF_NAME, STATE_UNKNOWN) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['eliqonline==1.0.13'] +REQUIREMENTS = ['eliqonline==1.0.14'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/fido.py b/homeassistant/components/sensor/fido.py index 25a104bf259..a2ee18b3659 100644 --- a/homeassistant/components/sensor/fido.py +++ b/homeassistant/components/sensor/fido.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyfido==2.1.0'] +REQUIREMENTS = ['pyfido==2.1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/file.py b/homeassistant/components/sensor/file.py index afa305a0fb0..cbdd4eef227 100644 --- a/homeassistant/components/sensor/file.py +++ b/homeassistant/components/sensor/file.py @@ -25,7 +25,7 @@ DEFAULT_NAME = 'File' ICON = 'mdi:file' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_FILE_PATH): cv.string, + vol.Required(CONF_FILE_PATH): cv.isfile, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, @@ -43,8 +43,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if value_template is not None: value_template.hass = hass - async_add_devices( - [FileSensor(name, file_path, unit, value_template)], True) + if hass.config.is_allowed_path(file_path): + async_add_devices( + [FileSensor(name, file_path, unit, value_template)], True) + else: + _LOGGER.error("'%s' is not a whitelisted directory", file_path) class FileSensor(Entity): diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index cde50699b29..9c05028b394 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -8,6 +8,9 @@ import logging import statistics from collections import deque, Counter from numbers import Number +from functools import partial +from copy import copy +from datetime import timedelta import voluptuous as vol @@ -20,12 +23,15 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.decorator import Registry from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change +import homeassistant.components.history as history +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) FILTER_NAME_LOWPASS = 'lowpass' FILTER_NAME_OUTLIER = 'outlier' FILTER_NAME_THROTTLE = 'throttle' +FILTER_NAME_TIME_SMA = 'time_simple_moving_average' FILTERS = Registry() CONF_FILTERS = 'filters' @@ -34,6 +40,12 @@ CONF_FILTER_WINDOW_SIZE = 'window_size' CONF_FILTER_PRECISION = 'precision' CONF_FILTER_RADIUS = 'radius' CONF_FILTER_TIME_CONSTANT = 'time_constant' +CONF_TIME_SMA_TYPE = 'type' + +TIME_SMA_LAST = 'last' + +WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 +WINDOW_SIZE_UNIT_TIME = 2 DEFAULT_WINDOW_SIZE = 1 DEFAULT_PRECISION = 2 @@ -44,26 +56,41 @@ NAME_TEMPLATE = "{} filter" ICON = 'mdi:chart-line-variant' FILTER_SCHEMA = vol.Schema({ - vol.Optional(CONF_FILTER_WINDOW_SIZE, - default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), }) +# pylint: disable=redefined-builtin FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_OUTLIER, + vol.Optional(CONF_FILTER_WINDOW_SIZE, + default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), vol.Optional(CONF_FILTER_RADIUS, default=DEFAULT_FILTER_RADIUS): vol.Coerce(float), }) FILTER_LOWPASS_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_LOWPASS, + vol.Optional(CONF_FILTER_WINDOW_SIZE, + default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), vol.Optional(CONF_FILTER_TIME_CONSTANT, default=DEFAULT_FILTER_TIME_CONSTANT): vol.Coerce(int), }) +FILTER_TIME_SMA_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_TIME_SMA, + vol.Optional(CONF_TIME_SMA_TYPE, + default=TIME_SMA_LAST): vol.In( + [TIME_SMA_LAST]), + + vol.Required(CONF_FILTER_WINDOW_SIZE): vol.All(cv.time_period, + cv.positive_timedelta) +}) + FILTER_THROTTLE_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_THROTTLE, + vol.Optional(CONF_FILTER_WINDOW_SIZE, + default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -72,6 +99,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_FILTERS): vol.All(cv.ensure_list, [vol.Any(FILTER_OUTLIER_SCHEMA, FILTER_LOWPASS_SCHEMA, + FILTER_TIME_SMA_SCHEMA, FILTER_THROTTLE_SCHEMA)]) }) @@ -104,21 +132,22 @@ class SensorFilter(Entity): async def async_added_to_hass(self): """Register callbacks.""" @callback - def filter_sensor_state_listener(entity, old_state, new_state): + def filter_sensor_state_listener(entity, old_state, new_state, + update_ha=True): """Handle device state changes.""" if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: return - temp_state = new_state.state + temp_state = new_state try: for filt in self._filters: - filtered_state = filt.filter_state(temp_state) + filtered_state = filt.filter_state(copy(temp_state)) _LOGGER.debug("%s(%s=%s) -> %s", filt.name, self._entity, - temp_state, + temp_state.state, "skip" if filt.skip_processing else - filtered_state) + filtered_state.state) if filt.skip_processing: return temp_state = filtered_state @@ -127,7 +156,7 @@ class SensorFilter(Entity): self._state) return - self._state = temp_state + self._state = temp_state.state if self._icon is None: self._icon = new_state.attributes.get( @@ -137,7 +166,50 @@ class SensorFilter(Entity): self._unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT) - self.async_schedule_update_ha_state() + if update_ha: + self.async_schedule_update_ha_state() + + if 'recorder' in self.hass.config.components: + history_list = [] + largest_window_items = 0 + largest_window_time = timedelta(0) + + # Determine the largest window_size by type + for filt in self._filters: + if filt.window_unit == WINDOW_SIZE_UNIT_NUMBER_EVENTS\ + and largest_window_items < filt.window_size: + largest_window_items = filt.window_size + elif filt.window_unit == WINDOW_SIZE_UNIT_TIME\ + and largest_window_time < filt.window_size: + largest_window_time = filt.window_size + + # Retrieve the largest window_size of each type + if largest_window_items > 0: + filter_history = await self.hass.async_add_job(partial( + history.get_last_state_changes, self.hass, + largest_window_items, entity_id=self._entity)) + history_list.extend( + [state for state in filter_history[self._entity]]) + if largest_window_time > timedelta(seconds=0): + start = dt_util.utcnow() - largest_window_time + filter_history = await self.hass.async_add_job(partial( + history.state_changes_during_period, self.hass, + start, entity_id=self._entity)) + history_list.extend( + [state for state in filter_history[self._entity] + if state not in history_list]) + + # Sort the window states + history_list = sorted(history_list, key=lambda s: s.last_updated) + _LOGGER.debug("Loading from history: %s", + [(s.state, s.last_updated) for s in history_list]) + + # Replay history through the filter chain + prev_state = None + for state in history_list: + filter_sensor_state_listener( + self._entity, prev_state, state, False) + prev_state = state async_track_state_change( self.hass, self._entity, filter_sensor_state_listener) @@ -176,6 +248,31 @@ class SensorFilter(Entity): return state_attr +class FilterState(object): + """State abstraction for filter usage.""" + + def __init__(self, state): + """Initialize with HA State object.""" + self.timestamp = state.last_updated + try: + self.state = float(state.state) + except ValueError: + self.state = state.state + + def set_precision(self, precision): + """Set precision of Number based states.""" + if isinstance(self.state, Number): + self.state = round(float(self.state), precision) + + def __str__(self): + """Return state as the string representation of FilterState.""" + return str(self.state) + + def __repr__(self): + """Return timestamp and state as the representation of FilterState.""" + return "{} : {}".format(self.timestamp, self.state) + + class Filter(object): """Filter skeleton. @@ -188,11 +285,22 @@ class Filter(object): def __init__(self, name, window_size=1, precision=None, entity=None): """Initialize common attributes.""" - self.states = deque(maxlen=window_size) + if isinstance(window_size, int): + self.states = deque(maxlen=window_size) + self.window_unit = WINDOW_SIZE_UNIT_NUMBER_EVENTS + else: + self.states = deque(maxlen=0) + self.window_unit = WINDOW_SIZE_UNIT_TIME self.precision = precision self._name = name self._entity = entity self._skip_processing = False + self._window_size = window_size + + @property + def window_size(self): + """Return window size.""" + return self._window_size @property def name(self): @@ -210,11 +318,11 @@ class Filter(object): def filter_state(self, new_state): """Implement a common interface for filters.""" - filtered = self._filter_state(new_state) - if isinstance(filtered, Number): - filtered = round(float(filtered), self.precision) - self.states.append(filtered) - return filtered + filtered = self._filter_state(FilterState(new_state)) + filtered.set_precision(self.precision) + self.states.append(copy(filtered)) + new_state.state = filtered.state + return new_state @FILTERS.register(FILTER_NAME_OUTLIER) @@ -235,11 +343,10 @@ class OutlierFilter(Filter): def _filter_state(self, new_state): """Implement the outlier filter.""" - new_state = float(new_state) - - if (self.states and - abs(new_state - statistics.median(self.states)) - > self._radius): + if (len(self.states) == self.states.maxlen and + abs(new_state.state - + statistics.median([s.state for s in self.states])) > + self._radius): self._stats_internal['erasures'] += 1 @@ -265,16 +372,59 @@ class LowPassFilter(Filter): def _filter_state(self, new_state): """Implement the low pass filter.""" - new_state = float(new_state) - if not self.states: return new_state new_weight = 1.0 / self._time_constant prev_weight = 1.0 - new_weight - filtered = prev_weight * self.states[-1] + new_weight * new_state + new_state.state = prev_weight * self.states[-1].state +\ + new_weight * new_state.state - return filtered + return new_state + + +@FILTERS.register(FILTER_NAME_TIME_SMA) +class TimeSMAFilter(Filter): + """Simple Moving Average (SMA) Filter. + + The window_size is determined by time, and SMA is time weighted. + + Args: + variant (enum): type of argorithm used to connect discrete values + """ + + def __init__(self, window_size, precision, entity, type): + """Initialize Filter.""" + super().__init__(FILTER_NAME_TIME_SMA, window_size, precision, entity) + self._time_window = window_size + self.last_leak = None + self.queue = deque() + + def _leak(self, left_boundary): + """Remove timeouted elements.""" + while self.queue: + if self.queue[0].timestamp + self._time_window <= left_boundary: + self.last_leak = self.queue.popleft() + else: + return + + def _filter_state(self, new_state): + """Implement the Simple Moving Average filter.""" + self._leak(new_state.timestamp) + self.queue.append(copy(new_state)) + + moving_sum = 0 + start = new_state.timestamp - self._time_window + prev_state = self.last_leak or self.queue[0] + for state in self.queue: + moving_sum += (state.timestamp-start).total_seconds()\ + * prev_state.state + start = state.timestamp + prev_state = state + + new_state.state = moving_sum / self._time_window.total_seconds() + + return new_state @FILTERS.register(FILTER_NAME_THROTTLE) diff --git a/homeassistant/components/sensor/fints.py b/homeassistant/components/sensor/fints.py new file mode 100644 index 00000000000..798f74bb654 --- /dev/null +++ b/homeassistant/components/sensor/fints.py @@ -0,0 +1,285 @@ +""" +Read the balance of your bank accounts via FinTS. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.fints/ +""" + +from collections import namedtuple +from datetime import timedelta +import logging +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_USERNAME, CONF_PIN, CONF_URL, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['fints==0.2.1'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(hours=4) + +ICON = 'mdi:currency-eur' + +BankCredentials = namedtuple('BankCredentials', 'blz login pin url') + +CONF_BIN = 'bank_identification_number' +CONF_ACCOUNTS = 'accounts' +CONF_HOLDINGS = 'holdings' +CONF_ACCOUNT = 'account' + +ATTR_ACCOUNT = CONF_ACCOUNT +ATTR_BANK = 'bank' +ATTR_ACCOUNT_TYPE = 'account_type' + +SCHEMA_ACCOUNTS = vol.Schema({ + vol.Required(CONF_ACCOUNT): cv.string, + vol.Optional(CONF_NAME, default=None): vol.Any(None, cv.string), +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_BIN): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PIN): cv.string, + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ACCOUNTS, default=[]): cv.ensure_list(SCHEMA_ACCOUNTS), + vol.Optional(CONF_HOLDINGS, default=[]): cv.ensure_list(SCHEMA_ACCOUNTS), +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the sensors. + + Login to the bank and get a list of existing accounts. Create a + sensor for each account. + """ + credentials = BankCredentials(config[CONF_BIN], config[CONF_USERNAME], + config[CONF_PIN], config[CONF_URL]) + fints_name = config.get(CONF_NAME, config[CONF_BIN]) + + account_config = {acc[CONF_ACCOUNT]: acc[CONF_NAME] + for acc in config[CONF_ACCOUNTS]} + + holdings_config = {acc[CONF_ACCOUNT]: acc[CONF_NAME] + for acc in config[CONF_HOLDINGS]} + + client = FinTsClient(credentials, fints_name) + balance_accounts, holdings_accounts = client.detect_accounts() + accounts = [] + + for account in balance_accounts: + if config[CONF_ACCOUNTS] and account.iban not in account_config: + _LOGGER.info('skipping account %s for bank %s', + account.iban, fints_name) + continue + + account_name = account_config.get(account.iban) + if not account_name: + account_name = '{} - {}'.format(fints_name, account.iban) + accounts.append(FinTsAccount(client, account, account_name)) + _LOGGER.debug('Creating account %s for bank %s', + account.iban, fints_name) + + for account in holdings_accounts: + if config[CONF_HOLDINGS] and \ + account.accountnumber not in holdings_config: + _LOGGER.info('skipping holdings %s for bank %s', + account.accountnumber, fints_name) + continue + + account_name = holdings_config.get(account.accountnumber) + if not account_name: + account_name = '{} - {}'.format( + fints_name, account.accountnumber) + accounts.append(FinTsHoldingsAccount(client, account, account_name)) + _LOGGER.debug('Creating holdings %s for bank %s', + account.accountnumber, fints_name) + + add_devices(accounts, True) + + +class FinTsClient(object): + """Wrapper around the FinTS3PinTanClient. + + Use this class as Context Manager to get the FinTS3Client object. + """ + + def __init__(self, credentials: BankCredentials, name: str): + """Constructor for class FinTsClient.""" + self._credentials = credentials + self.name = name + + @property + def client(self): + """Get the client object. + + As the fints library is stateless, there is not benefit in caching + the client objects. If that ever changes, consider caching the client + object and also think about potential concurrency problems. + """ + from fints.client import FinTS3PinTanClient + return FinTS3PinTanClient( + self._credentials.blz, self._credentials.login, + self._credentials.pin, self._credentials.url) + + def detect_accounts(self): + """Identify the accounts of the bank.""" + from fints.dialog import FinTSDialogError + balance_accounts = [] + holdings_accounts = [] + for account in self.client.get_sepa_accounts(): + try: + self.client.get_balance(account) + balance_accounts.append(account) + except IndexError: + # account is not a balance account. + pass + except FinTSDialogError: + # account is not a balance account. + pass + try: + self.client.get_holdings(account) + holdings_accounts.append(account) + except FinTSDialogError: + # account is not a holdings account. + pass + + return balance_accounts, holdings_accounts + + +class FinTsAccount(Entity): + """Sensor for a FinTS balanc account. + + A balance account contains an amount of money (=balance). The amount may + also be negative. + """ + + def __init__(self, client: FinTsClient, account, name: str) -> None: + """Constructor for class FinTsAccount.""" + self._client = client # type: FinTsClient + self._account = account + self._name = name # type: str + self._balance = None # type: float + self._currency = None # type: str + + @property + def should_poll(self) -> bool: + """Data needs to be polled from the bank servers.""" + return True + + def update(self) -> None: + """Get the current balance and currency for the account.""" + bank = self._client.client + balance = bank.get_balance(self._account) + self._balance = balance.amount.amount + self._currency = balance.amount.currency + _LOGGER.debug('updated balance of account %s', self.name) + + @property + def name(self) -> str: + """Friendly name of the sensor.""" + return self._name + + @property + def state(self) -> float: + """Return the balance of the account as state.""" + return self._balance + + @property + def unit_of_measurement(self) -> str: + """Use the currency as unit of measurement.""" + return self._currency + + @property + def device_state_attributes(self) -> dict: + """Additional attributes of the sensor.""" + attributes = { + ATTR_ACCOUNT: self._account.iban, + ATTR_ACCOUNT_TYPE: 'balance', + } + if self._client.name: + attributes[ATTR_BANK] = self._client.name + return attributes + + @property + def icon(self) -> str: + """Set the icon for the sensor.""" + return ICON + + +class FinTsHoldingsAccount(Entity): + """Sensor for a FinTS holdings account. + + A holdings account does not contain money but rather some financial + instruments, e.g. stocks. + """ + + def __init__(self, client: FinTsClient, account, name: str) -> None: + """Constructor for class FinTsHoldingsAccount.""" + self._client = client # type: FinTsClient + self._name = name # type: str + self._account = account + self._holdings = [] + self._total = None # type: float + + @property + def should_poll(self) -> bool: + """Data needs to be polled from the bank servers.""" + return True + + def update(self) -> None: + """Get the current holdings for the account.""" + bank = self._client.client + self._holdings = bank.get_holdings(self._account) + self._total = sum(h.total_value for h in self._holdings) + + @property + def state(self) -> float: + """Return total market value as state.""" + return self._total + + @property + def icon(self) -> str: + """Set the icon for the sensor.""" + return ICON + + @property + def device_state_attributes(self) -> dict: + """Additional attributes of the sensor. + + Lists each holding of the account with the current value. + """ + attributes = { + ATTR_ACCOUNT: self._account.accountnumber, + ATTR_ACCOUNT_TYPE: 'holdings', + } + if self._client.name: + attributes[ATTR_BANK] = self._client.name + for holding in self._holdings: + total_name = '{} total'.format(holding.name) + attributes[total_name] = holding.total_value + pieces_name = '{} pieces'.format(holding.name) + attributes[pieces_name] = holding.pieces + price_name = '{} price'.format(holding.name) + attributes[price_name] = holding.market_value + + return attributes + + @property + def name(self) -> str: + """Friendly name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self) -> str: + """Get the unit of measurement. + + Hardcoded to EUR, as the library does not provide the currency for the + holdings. And as FinTS is only used in Germany, most accounts will be + in EUR anyways. + """ + return "EUR" diff --git a/homeassistant/components/sensor/folder.py b/homeassistant/components/sensor/folder.py index a185cd1e825..2b5f3dd4309 100644 --- a/homeassistant/components/sensor/folder.py +++ b/homeassistant/components/sensor/folder.py @@ -38,7 +38,7 @@ def get_files_list(folder_path, filter_term): def get_size(files_list): """Return the sum of the size in bytes of files in the list.""" - size_list = [os.stat(f).st_size for f in files_list] + size_list = [os.stat(f).st_size for f in files_list if os.path.isfile(f)] return sum(size_list) diff --git a/homeassistant/components/sensor/foobot.py b/homeassistant/components/sensor/foobot.py new file mode 100644 index 00000000000..d247a90e93a --- /dev/null +++ b/homeassistant/components/sensor/foobot.py @@ -0,0 +1,158 @@ +""" +Support for the Foobot indoor air quality monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.foobot/ +""" +import asyncio +import logging +from datetime import timedelta + +import aiohttp +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import PlatformNotReady +from homeassistant.const import ( + ATTR_TIME, ATTR_TEMPERATURE, CONF_TOKEN, CONF_USERNAME, TEMP_CELSIUS) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + + +REQUIREMENTS = ['foobot_async==0.3.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_HUMIDITY = 'humidity' +ATTR_PM2_5 = 'PM2.5' +ATTR_CARBON_DIOXIDE = 'CO2' +ATTR_VOLATILE_ORGANIC_COMPOUNDS = 'VOC' +ATTR_FOOBOT_INDEX = 'index' + +SENSOR_TYPES = {'time': [ATTR_TIME, 's'], + 'pm': [ATTR_PM2_5, 'µg/m3', 'mdi:cloud'], + 'tmp': [ATTR_TEMPERATURE, TEMP_CELSIUS, 'mdi:thermometer'], + 'hum': [ATTR_HUMIDITY, '%', 'mdi:water-percent'], + 'co2': [ATTR_CARBON_DIOXIDE, 'ppm', + 'mdi:periodic-table-co2'], + 'voc': [ATTR_VOLATILE_ORGANIC_COMPOUNDS, 'ppb', + 'mdi:cloud'], + 'allpollu': [ATTR_FOOBOT_INDEX, '%', 'mdi:percent']} + +SCAN_INTERVAL = timedelta(minutes=10) +PARALLEL_UPDATES = 1 + +TIMEOUT = 10 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Required(CONF_USERNAME): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the devices associated with the account.""" + from foobot_async import FoobotClient + + token = config.get(CONF_TOKEN) + username = config.get(CONF_USERNAME) + + client = FoobotClient(token, username, + async_get_clientsession(hass), + timeout=TIMEOUT) + dev = [] + try: + devices = await client.get_devices() + _LOGGER.debug("The following devices were found: %s", devices) + for device in devices: + foobot_data = FoobotData(client, device['uuid']) + for sensor_type in SENSOR_TYPES: + if sensor_type == 'time': + continue + foobot_sensor = FoobotSensor(foobot_data, device, sensor_type) + dev.append(foobot_sensor) + except (aiohttp.client_exceptions.ClientConnectorError, + asyncio.TimeoutError, FoobotClient.TooManyRequests, + FoobotClient.InternalError): + _LOGGER.exception('Failed to connect to foobot servers.') + raise PlatformNotReady + except FoobotClient.ClientError: + _LOGGER.error('Failed to fetch data from foobot servers.') + return + async_add_devices(dev, True) + + +class FoobotSensor(Entity): + """Implementation of a Foobot sensor.""" + + def __init__(self, data, device, sensor_type): + """Initialize the sensor.""" + self._uuid = device['uuid'] + self.foobot_data = data + self._name = 'Foobot {} {}'.format(device['name'], + SENSOR_TYPES[sensor_type][0]) + self.type = sensor_type + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Icon to use in the frontend.""" + return SENSOR_TYPES[self.type][2] + + @property + def state(self): + """Return the state of the device.""" + try: + data = self.foobot_data.data[self.type] + except(KeyError, TypeError): + data = None + return data + + @property + def unique_id(self): + """Return the unique id of this entity.""" + return "{}_{}".format(self._uuid, self.type) + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit_of_measurement + + async def async_update(self): + """Get the latest data.""" + await self.foobot_data.async_update() + + +class FoobotData(Entity): + """Get data from Foobot API.""" + + def __init__(self, client, uuid): + """Initialize the data object.""" + self._client = client + self._uuid = uuid + self.data = {} + + @Throttle(SCAN_INTERVAL) + async def async_update(self): + """Get the data from Foobot API.""" + interval = SCAN_INTERVAL.total_seconds() + try: + response = await self._client.get_last_data(self._uuid, + interval, + interval + 1) + except (aiohttp.client_exceptions.ClientConnectorError, + asyncio.TimeoutError, self._client.TooManyRequests, + self._client.InternalError): + _LOGGER.debug("Couldn't fetch data") + return False + _LOGGER.debug("The data response is: %s", response) + self.data = {k: round(v, 1) for k, v in response[0].items()} + return True diff --git a/homeassistant/components/sensor/fritzbox_netmonitor.py b/homeassistant/components/sensor/fritzbox_netmonitor.py index f4f774cad1e..857e6cc4a07 100644 --- a/homeassistant/components/sensor/fritzbox_netmonitor.py +++ b/homeassistant/components/sensor/fritzbox_netmonitor.py @@ -11,7 +11,7 @@ from requests.exceptions import RequestException import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_HOST, STATE_UNAVAILABLE) +from homeassistant.const import (CONF_NAME, CONF_HOST, STATE_UNAVAILABLE) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -20,6 +20,7 @@ REQUIREMENTS = ['fritzconnection==0.6.5'] _LOGGER = logging.getLogger(__name__) +CONF_DEFAULT_NAME = 'fritz_netmonitor' CONF_DEFAULT_IP = '169.254.1.1' # This IP is valid for all FRITZ!Box routers. ATTR_BYTES_RECEIVED = 'bytes_received' @@ -42,6 +43,7 @@ STATE_OFFLINE = 'offline' ICON = 'mdi:web' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=CONF_DEFAULT_NAME): cv.string, vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, }) @@ -52,6 +54,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import fritzconnection as fc from fritzconnection.fritzconnection import FritzConnectionException + name = config.get(CONF_NAME) host = config.get(CONF_HOST) try: @@ -65,15 +68,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): else: _LOGGER.info("Successfully connected to FRITZ!Box") - add_devices([FritzboxMonitorSensor(fstatus)], True) + add_devices([FritzboxMonitorSensor(name, fstatus)], True) class FritzboxMonitorSensor(Entity): """Implementation of a fritzbox monitor sensor.""" - def __init__(self, fstatus): + def __init__(self, name, fstatus): """Initialize the sensor.""" - self._name = 'fritz_netmonitor' + self._name = name self._fstatus = fstatus self._state = STATE_UNAVAILABLE self._is_linked = self._is_connected = self._wan_access_type = None diff --git a/homeassistant/components/sensor/gitter.py b/homeassistant/components/sensor/gitter.py index 58f33635750..907af07a2db 100644 --- a/homeassistant/components/sensor/gitter.py +++ b/homeassistant/components/sensor/gitter.py @@ -8,12 +8,12 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, CONF_API_KEY, CONF_ROOM +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_ROOM +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['gitterpy==0.1.6'] +REQUIREMENTS = ['gitterpy==0.1.7'] _LOGGER = logging.getLogger(__name__) @@ -47,7 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): username = gitter.auth.get_my_id['name'] except GitterTokenError: _LOGGER.error("Token is not valid") - return False + return add_devices([GitterSensor(gitter, room, name, username)], True) @@ -96,7 +96,14 @@ class GitterSensor(Entity): def update(self): """Get the latest data and updates the state.""" - data = self._data.user.unread_items(self._room) + from gitterpy.errors import GitterRoomError + + try: + data = self._data.user.unread_items(self._room) + except GitterRoomError as error: + _LOGGER.error(error) + return + if 'error' not in data.keys(): self._mention = len(data['mention']) self._state = len(data['chat']) diff --git a/homeassistant/components/sensor/glances.py b/homeassistant/components/sensor/glances.py index 3b6f3ddc99d..0de87bd17ea 100644 --- a/homeassistant/components/sensor/glances.py +++ b/homeassistant/components/sensor/glances.py @@ -161,7 +161,8 @@ class GlancesSensor(Entity): elif self.type == 'docker_active': count = 0 for container in value['docker']['containers']: - if container['Status'] == 'running': + if container['Status'] == 'running' or \ + 'Up' in container['Status']: count += 1 self._state = count elif self.type == 'docker_cpu_use': diff --git a/homeassistant/components/sensor/hive.py b/homeassistant/components/sensor/hive.py index cae2eaf7437..82816c83404 100644 --- a/homeassistant/components/sensor/hive.py +++ b/homeassistant/components/sensor/hive.py @@ -4,11 +4,17 @@ Support for the Hive devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.hive/ """ +from homeassistant.const import TEMP_CELSIUS from homeassistant.components.hive import DATA_HIVE from homeassistant.helpers.entity import Entity DEPENDENCIES = ['hive'] +FRIENDLY_NAMES = {'Hub_OnlineStatus': 'Hub Status', + 'Hive_OutsideTemperature': 'Outside Temperature'} +DEVICETYPE_ICONS = {'Hub_OnlineStatus': 'mdi:switch', + 'Hive_OutsideTemperature': 'mdi:thermometer'} + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Hive sensor devices.""" @@ -16,7 +22,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return session = hass.data.get(DATA_HIVE) - if discovery_info["HA_DeviceType"] == "Hub_OnlineStatus": + if (discovery_info["HA_DeviceType"] == "Hub_OnlineStatus" or + discovery_info["HA_DeviceType"] == "Hive_OutsideTemperature"): add_devices([HiveSensorEntity(session, discovery_info)]) @@ -27,6 +34,7 @@ class HiveSensorEntity(Entity): """Initialize the sensor.""" self.node_id = hivedevice["Hive_NodeID"] self.device_type = hivedevice["HA_DeviceType"] + self.node_device_type = hivedevice["Hive_DeviceType"] self.session = hivesession self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) @@ -40,13 +48,29 @@ class HiveSensorEntity(Entity): @property def name(self): """Return the name of the sensor.""" - return "Hive hub status" + return FRIENDLY_NAMES.get(self.device_type) @property def state(self): """Return the state of the sensor.""" - return self.session.sensor.hub_online_status(self.node_id) + if self.device_type == "Hub_OnlineStatus": + return self.session.sensor.hub_online_status(self.node_id) + elif self.device_type == "Hive_OutsideTemperature": + return self.session.weather.temperature() + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self.device_type == "Hive_OutsideTemperature": + return TEMP_CELSIUS + + @property + def icon(self): + """Return the icon to use.""" + return DEVICETYPE_ICONS.get(self.device_type) def update(self): """Update all Node data from Hive.""" - self.session.core.update_data(self.node_id) + if self.session.core.update_data(self.node_id): + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index 350f1e2eb59..bdbc207a79c 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -43,7 +43,7 @@ HM_UNIT_HA_CAST = { 'ENERGY_COUNTER': 'Wh', 'GAS_POWER': 'm3', 'GAS_ENERGY_COUNTER': 'm3', - 'LUX': 'lux', + 'LUX': 'lx', 'RAIN_COUNTER': 'mm', 'WIND_SPEED': 'km/h', 'WIND_DIRECTION': '°', diff --git a/homeassistant/components/sensor/homematicip_cloud.py b/homeassistant/components/sensor/homematicip_cloud.py new file mode 100644 index 00000000000..ccd1949cc3b --- /dev/null +++ b/homeassistant/components/sensor/homematicip_cloud.py @@ -0,0 +1,225 @@ +""" +Support for HomematicIP sensors. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.homematicip_cloud/ +""" + +import logging + +from homeassistant.components.homematicip_cloud import ( + HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, + ATTR_HOME_ID) +from homeassistant.const import ( + TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['homematicip_cloud'] + +ATTR_VALVE_STATE = 'valve_state' +ATTR_VALVE_POSITION = 'valve_position' +ATTR_TEMPERATURE = 'temperature' +ATTR_TEMPERATURE_OFFSET = 'temperature_offset' +ATTR_HUMIDITY = 'humidity' + +HMIP_UPTODATE = 'up_to_date' +HMIP_VALVE_DONE = 'adaption_done' +HMIP_SABOTAGE = 'sabotage' + +STATE_OK = 'ok' +STATE_LOW_BATTERY = 'low_battery' +STATE_SABOTAGE = 'sabotage' + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the HomematicIP sensors devices.""" + from homematicip.device import ( + HeatingThermostat, TemperatureHumiditySensorWithoutDisplay, + TemperatureHumiditySensorDisplay, MotionDetectorIndoor) + + if discovery_info is None: + return + home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]] + devices = [HomematicipAccesspointStatus(home)] + + for device in home.devices: + if isinstance(device, HeatingThermostat): + devices.append(HomematicipHeatingThermostat(home, device)) + if isinstance(device, (TemperatureHumiditySensorDisplay, + TemperatureHumiditySensorWithoutDisplay)): + devices.append(HomematicipTemperatureSensor(home, device)) + devices.append(HomematicipHumiditySensor(home, device)) + if isinstance(device, MotionDetectorIndoor): + devices.append(HomematicipIlluminanceSensor(home, device)) + + if devices: + async_add_devices(devices) + + +class HomematicipAccesspointStatus(HomematicipGenericDevice): + """Representation of an HomeMaticIP access point.""" + + def __init__(self, home): + """Initialize access point device.""" + super().__init__(home, home) + + @property + def icon(self): + """Return the icon of the access point device.""" + return 'mdi:access-point-network' + + @property + def state(self): + """Return the state of the access point.""" + return self._home.dutyCycle + + @property + def available(self): + """Device available.""" + return self._home.connected + + @property + def device_state_attributes(self): + """Return the state attributes of the access point.""" + return {} + + +class HomematicipDeviceStatus(HomematicipGenericDevice): + """Representation of an HomematicIP device status.""" + + def __init__(self, home, device): + """Initialize generic status device.""" + super().__init__(home, device, 'Status') + + @property + def icon(self): + """Return the icon of the status device.""" + if (hasattr(self._device, 'sabotage') and + self._device.sabotage == HMIP_SABOTAGE): + return 'mdi:alert' + elif self._device.lowBat: + return 'mdi:battery-outline' + elif self._device.updateState.lower() != HMIP_UPTODATE: + return 'mdi:refresh' + return 'mdi:check' + + @property + def state(self): + """Return the state of the generic device.""" + if (hasattr(self._device, 'sabotage') and + self._device.sabotage == HMIP_SABOTAGE): + return STATE_SABOTAGE + elif self._device.lowBat: + return STATE_LOW_BATTERY + elif self._device.updateState.lower() != HMIP_UPTODATE: + return self._device.updateState.lower() + return STATE_OK + + +class HomematicipHeatingThermostat(HomematicipGenericDevice): + """MomematicIP heating thermostat representation.""" + + def __init__(self, home, device): + """Initialize heating thermostat device.""" + super().__init__(home, device, 'Heating') + + @property + def icon(self): + """Return the icon.""" + if self._device.valveState.lower() != HMIP_VALVE_DONE: + return 'mdi:alert' + return 'mdi:radiator' + + @property + def state(self): + """Return the state of the radiator valve.""" + if self._device.valveState.lower() != HMIP_VALVE_DONE: + return self._device.valveState.lower() + return round(self._device.valvePosition*100) + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return '%' + + +class HomematicipHumiditySensor(HomematicipGenericDevice): + """MomematicIP humidity device.""" + + def __init__(self, home, device): + """Initialize the thermometer device.""" + super().__init__(home, device, 'Humidity') + + @property + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_HUMIDITY + + @property + def icon(self): + """Return the icon.""" + return 'mdi:water-percent' + + @property + def state(self): + """Return the state.""" + return self._device.humidity + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return '%' + + +class HomematicipTemperatureSensor(HomematicipGenericDevice): + """MomematicIP the thermometer device.""" + + def __init__(self, home, device): + """Initialize the thermometer device.""" + super().__init__(home, device, 'Temperature') + + @property + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_TEMPERATURE + + @property + def icon(self): + """Return the icon.""" + return 'mdi:thermometer' + + @property + def state(self): + """Return the state.""" + return self._device.actualTemperature + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return TEMP_CELSIUS + + +class HomematicipIlluminanceSensor(HomematicipGenericDevice): + """MomematicIP the thermometer device.""" + + def __init__(self, home, device): + """Initialize the device.""" + super().__init__(home, device, 'Illuminance') + + @property + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_ILLUMINANCE + + @property + def state(self): + """Return the state.""" + return self._device.illumination + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return 'lx' diff --git a/homeassistant/components/sensor/hydrawise.py b/homeassistant/components/sensor/hydrawise.py new file mode 100644 index 00000000000..fea2780da07 --- /dev/null +++ b/homeassistant/components/sensor/hydrawise.py @@ -0,0 +1,72 @@ +""" +Support for Hydrawise sprinkler. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.hydrawise/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.hydrawise import ( + DATA_HYDRAWISE, HydrawiseEntity, DEVICE_MAP, DEVICE_MAP_INDEX, SENSORS) +from homeassistant.components.sensor import 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=SENSORS): + vol.All(cv.ensure_list, [vol.In(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): + for zone in hydrawise.relays: + sensors.append(HydrawiseSensor(zone, sensor_type)) + + add_devices(sensors, True) + + +class HydrawiseSensor(HydrawiseEntity): + """A sensor implementation for Hydrawise device.""" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Get the latest data and updates the states.""" + mydata = self.hass.data[DATA_HYDRAWISE].data + _LOGGER.debug("Updating Hydrawise sensor: %s", self._name) + if self._sensor_type == 'watering_time': + if not mydata.running: + self._state = 0 + else: + if int(mydata.running[0]['relay']) == self.data['relay']: + self._state = int(mydata.running[0]['time_left']/60) + else: + self._state = 0 + else: # _sensor_type == 'next_cycle' + for relay in mydata.relays: + if relay['relay'] == self.data['relay']: + if relay['nicetime'] == 'Not scheduled': + self._state = 'not_scheduled' + else: + self._state = relay['nicetime'].split(',')[0] + \ + ' ' + relay['nicetime'].split(' ')[3] + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return DEVICE_MAP[self._sensor_type][ + DEVICE_MAP_INDEX.index('ICON_INDEX')] diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index 3678ac9268f..2195153ab1e 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyhydroquebec==2.1.0'] +REQUIREMENTS = ['pyhydroquebec==2.2.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/imap_email_content.py b/homeassistant/components/sensor/imap_email_content.py index 1f04cd606d6..c0c9bf62efd 100644 --- a/homeassistant/components/sensor/imap_email_content.py +++ b/homeassistant/components/sensor/imap_email_content.py @@ -87,6 +87,8 @@ class EmailReader(object): _, message_data = self.connection.uid( 'fetch', message_uid, '(RFC822)') + if message_data is None: + return None raw_email = message_data[0][1] email_message = email.message_from_bytes(raw_email) return email_message diff --git a/homeassistant/components/sensor/insteon_plm.py b/homeassistant/components/sensor/insteon_plm.py index a72b8efbc05..61f5877ed78 100644 --- a/homeassistant/components/sensor/insteon_plm.py +++ b/homeassistant/components/sensor/insteon_plm.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) @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] diff --git a/homeassistant/components/sensor/iota.py b/homeassistant/components/sensor/iota.py index c973fa83148..2e3e58a18f3 100644 --- a/homeassistant/components/sensor/iota.py +++ b/homeassistant/components/sensor/iota.py @@ -7,10 +7,18 @@ https://home-assistant.io/components/iota import logging from datetime import timedelta -from homeassistant.components.iota import IotaDevice +from homeassistant.components.iota import IotaDevice, CONF_WALLETS +from homeassistant.const import CONF_NAME _LOGGER = logging.getLogger(__name__) +ATTR_TESTNET = 'testnet' +ATTR_URL = 'url' + +CONF_IRI = 'iri' +CONF_SEED = 'seed' +CONF_TESTNET = 'testnet' + DEPENDENCIES = ['iota'] SCAN_INTERVAL = timedelta(minutes=3) @@ -21,7 +29,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # Add sensors for wallet balance iota_config = discovery_info sensors = [IotaBalanceSensor(wallet, iota_config) - for wallet in iota_config['wallets']] + for wallet in iota_config[CONF_WALLETS]] # Add sensor for node information sensors.append(IotaNodeSensor(iota_config=iota_config)) @@ -34,10 +42,9 @@ class IotaBalanceSensor(IotaDevice): def __init__(self, wallet_config, iota_config): """Initialize the sensor.""" - super().__init__(name=wallet_config['name'], - seed=wallet_config['seed'], - iri=iota_config['iri'], - is_testnet=iota_config['testnet']) + super().__init__( + name=wallet_config[CONF_NAME], seed=wallet_config[CONF_SEED], + iri=iota_config[CONF_IRI], is_testnet=iota_config[CONF_TESTNET]) self._state = None @property @@ -65,10 +72,11 @@ class IotaNodeSensor(IotaDevice): def __init__(self, iota_config): """Initialize the sensor.""" - super().__init__(name='Node Info', seed=None, iri=iota_config['iri'], - is_testnet=iota_config['testnet']) + super().__init__( + name='Node Info', seed=None, iri=iota_config[CONF_IRI], + is_testnet=iota_config[CONF_TESTNET]) self._state = None - self._attr = {'url': self.iri, 'testnet': self.is_testnet} + self._attr = {ATTR_URL: self.iri, ATTR_TESTNET: self.is_testnet} @property def name(self): diff --git a/homeassistant/components/sensor/iperf3.py b/homeassistant/components/sensor/iperf3.py new file mode 100644 index 00000000000..8e030390f50 --- /dev/null +++ b/homeassistant/components/sensor/iperf3.py @@ -0,0 +1,195 @@ +""" +Support for Iperf3 network measurement tool. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.iperf3/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN, PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_ENTITY_ID, CONF_MONITORED_CONDITIONS, + CONF_HOST, CONF_PORT, CONF_PROTOCOL) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['iperf3==0.1.10'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_PROTOCOL = 'Protocol' +ATTR_REMOTE_HOST = 'Remote Server' +ATTR_REMOTE_PORT = 'Remote Port' +ATTR_VERSION = 'Version' + +CONF_ATTRIBUTION = 'Data retrieved using Iperf3' +CONF_DURATION = 'duration' +CONF_PARALLEL = 'parallel' + +DEFAULT_DURATION = 10 +DEFAULT_PORT = 5201 +DEFAULT_PARALLEL = 1 +DEFAULT_PROTOCOL = 'tcp' + +IPERF3_DATA = 'iperf3' + +SCAN_INTERVAL = timedelta(minutes=60) + +SERVICE_NAME = 'iperf3_update' + +ICON = 'mdi:speedometer' + +SENSOR_TYPES = { + 'download': ['Download', 'Mbit/s'], + 'upload': ['Upload', 'Mbit/s'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.Range(5, 10), + vol.Optional(CONF_PARALLEL, default=DEFAULT_PARALLEL): vol.Range(1, 20), + vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): + vol.In(['tcp', 'udp']), +}) + + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Iperf3 sensor.""" + if hass.data.get(IPERF3_DATA) is None: + hass.data[IPERF3_DATA] = {} + hass.data[IPERF3_DATA]['sensors'] = [] + + dev = [] + for sensor in config[CONF_MONITORED_CONDITIONS]: + dev.append( + Iperf3Sensor(config[CONF_HOST], + config[CONF_PORT], + config[CONF_DURATION], + config[CONF_PARALLEL], + config[CONF_PROTOCOL], + sensor)) + + hass.data[IPERF3_DATA]['sensors'].extend(dev) + add_devices(dev) + + def _service_handler(service): + """Update service for manual updates.""" + entity_id = service.data.get('entity_id') + all_iperf3_sensors = hass.data[IPERF3_DATA]['sensors'] + + for sensor in all_iperf3_sensors: + if entity_id is not None: + if sensor.entity_id == entity_id: + sensor.update() + sensor.schedule_update_ha_state() + break + else: + sensor.update() + sensor.schedule_update_ha_state() + + for sensor in dev: + hass.services.register(DOMAIN, SERVICE_NAME, _service_handler, + schema=SERVICE_SCHEMA) + + +class Iperf3Sensor(Entity): + """A Iperf3 sensor implementation.""" + + def __init__(self, server, port, duration, streams, + protocol, sensor_type): + """Initialize the sensor.""" + self._attrs = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_PROTOCOL: protocol, + } + self._name = \ + "{} {}".format(SENSOR_TYPES[sensor_type][0], server) + self._state = None + self._sensor_type = sensor_type + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._port = port + self._server = server + self._duration = duration + self._num_streams = streams + self._protocol = protocol + self.result = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.result is not None: + self._attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + self._attrs[ATTR_REMOTE_HOST] = self.result.remote_host + self._attrs[ATTR_REMOTE_PORT] = self.result.remote_port + self._attrs[ATTR_VERSION] = self.result.version + return self._attrs + + def update(self): + """Get the latest data and update the states.""" + import iperf3 + client = iperf3.Client() + client.duration = self._duration + client.server_hostname = self._server + client.port = self._port + client.verbose = False + client.num_streams = self._num_streams + client.protocol = self._protocol + + # when testing download bandwith, reverse must be True + if self._sensor_type == 'download': + client.reverse = True + + try: + self.result = client.run() + except (AttributeError, OSError, ValueError) as error: + self.result = None + _LOGGER.error("Iperf3 sensor error: %s", error) + return + + if self.result is not None and \ + hasattr(self.result, 'error') and \ + self.result.error is not None: + _LOGGER.error("Iperf3 sensor error: %s", self.result.error) + self.result = None + return + + # UDP only have 1 way attribute + if self._protocol == 'udp': + self._state = round(self.result.Mbps, 2) + + elif self._sensor_type == 'download': + self._state = round(self.result.received_Mbps, 2) + + elif self._sensor_type == 'upload': + self._state = round(self.result.sent_Mbps, 2) + + @property + def icon(self): + """Return icon.""" + return ICON diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index c34a4a8fca7..ecf7bc0b8c2 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -49,7 +49,7 @@ UOM_FRIENDLY_NAME = { '33': 'kWH', '34': 'liedu', '35': 'l', - '36': 'lux', + '36': 'lx', '37': 'mercalli', '38': 'm', '39': 'm³/hr', diff --git a/homeassistant/components/sensor/lastfm.py b/homeassistant/components/sensor/lastfm.py index 9d305973ecf..9fec4b4b5e3 100644 --- a/homeassistant/components/sensor/lastfm.py +++ b/homeassistant/components/sensor/lastfm.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylast==2.1.0'] +REQUIREMENTS = ['pylast==2.2.0'] ATTR_LAST_PLAYED = 'last_played' ATTR_PLAY_COUNT = 'play_count' diff --git a/homeassistant/components/sensor/linux_battery.py b/homeassistant/components/sensor/linux_battery.py index 3d28c44d606..e7b8bf600a4 100644 --- a/homeassistant/components/sensor/linux_battery.py +++ b/homeassistant/components/sensor/linux_battery.py @@ -10,15 +10,14 @@ import os import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME -from homeassistant.helpers.entity import Entity +from homeassistant.const import ATTR_NAME, CONF_NAME, DEVICE_CLASS_BATTERY import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity REQUIREMENTS = ['batinfo==0.4.2'] _LOGGER = logging.getLogger(__name__) -ATTR_NAME = 'name' ATTR_PATH = 'path' ATTR_ALARM = 'alarm' ATTR_CAPACITY = 'capacity' @@ -48,8 +47,6 @@ DEFAULT_SYSTEM = 'linux' SYSTEMS = ['android', 'linux'] -ICON = 'mdi:battery' - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BATTERY, default=DEFAULT_BATTERY): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -94,6 +91,11 @@ class LinuxBatterySensor(Entity): """Return the name of the sensor.""" return self._name + @property + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_BATTERY + @property def state(self): """Return the state of the sensor.""" @@ -104,11 +106,6 @@ class LinuxBatterySensor(Entity): """Return the unit the value is expressed in.""" return self._unit_of_measurement - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def device_state_attributes(self): """Return the state attributes of the sensor.""" diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py index c5e0b12b0e0..9952e2a1c24 100644 --- a/homeassistant/components/sensor/luftdaten.py +++ b/homeassistant/components/sensor/luftdaten.py @@ -4,7 +4,6 @@ Support for Luftdaten sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.luftdaten/ """ -import asyncio from datetime import timedelta import logging @@ -19,7 +18,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['luftdaten==0.1.3'] +REQUIREMENTS = ['luftdaten==0.2.0'] _LOGGER = logging.getLogger(__name__) @@ -59,8 +58,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 Luftdaten sensor.""" from luftdaten import Luftdaten @@ -71,7 +70,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): session = async_get_clientsession(hass) luftdaten = LuftdatenData(Luftdaten(sensor_id, hass.loop, session)) - yield from luftdaten.async_update() + await luftdaten.async_update() if luftdaten.data is None: _LOGGER.error("Sensor is not available: %s", sensor_id) diff --git a/homeassistant/components/sensor/mercedesme.py b/homeassistant/components/sensor/mercedesme.py deleted file mode 100644 index bb7212678a7..00000000000 --- a/homeassistant/components/sensor/mercedesme.py +++ /dev/null @@ -1,87 +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/sensor.mercedesme/ -""" -import logging -import datetime - -from homeassistant.components.mercedesme import ( - DATA_MME, FEATURE_NOT_AVAILABLE, MercedesMeEntity, SENSORS) - - -DEPENDENCIES = ['mercedesme'] - -_LOGGER = logging.getLogger(__name__) - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the sensor platform.""" - if discovery_info is None: - return - - data = hass.data[DATA_MME].data - - if not data.cars: - return - - devices = [] - for car in data.cars: - for key, value in sorted(SENSORS.items()): - if car['availabilities'].get(key, 'INVALID') == 'VALID': - devices.append( - MercedesMESensor( - data, key, value[0], car["vin"], value[1])) - else: - _LOGGER.warning(FEATURE_NOT_AVAILABLE, key, car["license"]) - - add_devices(devices, True) - - -class MercedesMESensor(MercedesMeEntity): - """Representation of a Sensor.""" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - def update(self): - """Get the latest data and updates the states.""" - _LOGGER.debug("Updating %s", self._internal_name) - - self._car = next( - car for car in self._data.cars if car["vin"] == self._vin) - - if self._internal_name == "latestTrip": - self._state = self._car["latestTrip"]["id"] - else: - self._state = self._car[self._internal_name] - - @property - def device_state_attributes(self): - """Return the state attributes.""" - if self._internal_name == "latestTrip": - return { - "duration_seconds": - self._car["latestTrip"]["durationSeconds"], - "distance_traveled_km": - self._car["latestTrip"]["distanceTraveledKm"], - "started_at": datetime.datetime.fromtimestamp( - self._car["latestTrip"]["startedAt"] - ).strftime('%Y-%m-%d %H:%M:%S'), - "average_speed_km_per_hr": - self._car["latestTrip"]["averageSpeedKmPerHr"], - "finished": self._car["latestTrip"]["finished"], - "last_update": datetime.datetime.fromtimestamp( - self._car["lastUpdate"] - ).strftime('%Y-%m-%d %H:%M:%S'), - "car": self._car["license"] - } - - return { - "last_update": datetime.datetime.fromtimestamp( - self._car["lastUpdate"]).strftime('%Y-%m-%d %H:%M:%S'), - "car": self._car["license"] - } diff --git a/homeassistant/components/sensor/mfi.py b/homeassistant/components/sensor/mfi.py index ecea0815e79..f6bec3284c3 100644 --- a/homeassistant/components/sensor/mfi.py +++ b/homeassistant/components/sensor/mfi.py @@ -33,6 +33,7 @@ DIGITS = { SENSOR_MODELS = [ 'Ubiquiti mFi-THS', 'Ubiquiti mFi-CS', + 'Ubiquiti mFi-DS', 'Outlet', 'Input Analog', 'Input Digital', diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index 37976151190..f1f8adab062 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) -REQUIREMENTS = ['miflora==0.3.0'] +REQUIREMENTS = ['miflora==0.4.0'] _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ DEFAULT_TIMEOUT = 10 # Sensor types are defined like: Name, units SENSOR_TYPES = { 'temperature': ['Temperature', '°C'], - 'light': ['Light intensity', 'lux'], + 'light': ['Light intensity', 'lx'], 'moisture': ['Moisture', '%'], 'conductivity': ['Conductivity', 'µS/cm'], 'battery': ['Battery', '%'], @@ -63,10 +63,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): from miflora import miflora_poller try: import bluepy.btle # noqa: F401 # pylint: disable=unused-variable - from miflora.backends.bluepy import BluepyBackend + from btlewrap import BluepyBackend backend = BluepyBackend except ImportError: - from miflora.backends.gatttool import GatttoolBackend + from btlewrap import GatttoolBackend backend = GatttoolBackend _LOGGER.debug('Miflora is using %s backend.', backend.__name__) @@ -138,7 +138,7 @@ class MiFloraSensor(Entity): This uses a rolling median over 3 values to filter out outliers. """ - from miflora.backends import BluetoothBackendException + from btlewrap import BluetoothBackendException try: _LOGGER.debug("Polling data for %s", self.name) data = self.poller.parameter_value(self.parameter) diff --git a/homeassistant/components/sensor/mitemp_bt.py b/homeassistant/components/sensor/mitemp_bt.py new file mode 100644 index 00000000000..3628765293b --- /dev/null +++ b/homeassistant/components/sensor/mitemp_bt.py @@ -0,0 +1,172 @@ +""" +Support for Xiaomi Mi Temp BLE environmental sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mitemp_bt/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_FORCE_UPDATE, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC +) + + +REQUIREMENTS = ['mitemp_bt==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ADAPTER = 'adapter' +CONF_CACHE = 'cache_value' +CONF_MEDIAN = 'median' +CONF_RETRIES = 'retries' +CONF_TIMEOUT = 'timeout' + +DEFAULT_ADAPTER = 'hci0' +DEFAULT_UPDATE_INTERVAL = 300 +DEFAULT_FORCE_UPDATE = False +DEFAULT_MEDIAN = 3 +DEFAULT_NAME = 'MiTemp BT' +DEFAULT_RETRIES = 2 +DEFAULT_TIMEOUT = 10 + + +# Sensor types are defined like: Name, units +SENSOR_TYPES = { + 'temperature': ['Temperature', '°C'], + 'humidity': ['Humidity', '%'], + 'battery': ['Battery', '%'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MAC): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MEDIAN, default=DEFAULT_MEDIAN): cv.positive_int, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_RETRIES, default=DEFAULT_RETRIES): cv.positive_int, + vol.Optional(CONF_CACHE, default=DEFAULT_UPDATE_INTERVAL): cv.positive_int, + vol.Optional(CONF_ADAPTER, default=DEFAULT_ADAPTER): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the MiTempBt sensor.""" + from mitemp_bt import mitemp_bt_poller + try: + import bluepy.btle # noqa: F401 # pylint: disable=unused-variable + from btlewrap import BluepyBackend + backend = BluepyBackend + except ImportError: + from btlewrap import GatttoolBackend + backend = GatttoolBackend + _LOGGER.debug('MiTempBt is using %s backend.', backend.__name__) + + cache = config.get(CONF_CACHE) + poller = mitemp_bt_poller.MiTempBtPoller( + config.get(CONF_MAC), cache_timeout=cache, + adapter=config.get(CONF_ADAPTER), backend=backend) + force_update = config.get(CONF_FORCE_UPDATE) + median = config.get(CONF_MEDIAN) + poller.ble_timeout = config.get(CONF_TIMEOUT) + poller.retries = config.get(CONF_RETRIES) + + devs = [] + + for parameter in config[CONF_MONITORED_CONDITIONS]: + name = SENSOR_TYPES[parameter][0] + unit = SENSOR_TYPES[parameter][1] + + prefix = config.get(CONF_NAME) + if prefix: + name = "{} {}".format(prefix, name) + + devs.append(MiTempBtSensor( + poller, parameter, name, unit, force_update, median)) + + add_devices(devs) + + +class MiTempBtSensor(Entity): + """Implementing the MiTempBt sensor.""" + + def __init__(self, poller, parameter, name, unit, force_update, median): + """Initialize the sensor.""" + self.poller = poller + self.parameter = parameter + self._unit = unit + self._name = name + self._state = None + self.data = [] + self._force_update = force_update + # Median is used to filter out outliers. median of 3 will filter + # single outliers, while median of 5 will filter double outliers + # Use median_count = 1 if no filtering is required. + self.median_count = median + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return self._unit + + @property + def force_update(self): + """Force update.""" + return self._force_update + + def update(self): + """ + Update current conditions. + + This uses a rolling median over 3 values to filter out outliers. + """ + from btlewrap.base import BluetoothBackendException + try: + _LOGGER.debug("Polling data for %s", self.name) + data = self.poller.parameter_value(self.parameter) + except IOError as ioerr: + _LOGGER.warning("Polling error %s", ioerr) + return + except BluetoothBackendException as bterror: + _LOGGER.warning("Polling error %s", bterror) + return + + if data is not None: + _LOGGER.debug("%s = %s", self.name, data) + self.data.append(data) + else: + _LOGGER.warning("Did not receive any data from Mi Temp sensor %s", + self.name) + # Remove old data from median list or set sensor value to None + # if no data is available anymore + if self.data: + self.data = self.data[1:] + else: + self._state = None + return + + if len(self.data) > self.median_count: + self.data = self.data[1:] + + if len(self.data) == self.median_count: + median = sorted(self.data)[int((self.median_count - 1) / 2)] + _LOGGER.debug("Median is: %s", median) + self._state = median + else: + _LOGGER.debug("Not yet enough data for median calculation") diff --git a/homeassistant/components/sensor/moon.py b/homeassistant/components/sensor/moon.py index 75b8a1f72bd..0c57c98c0af 100644 --- a/homeassistant/components/sensor/moon.py +++ b/homeassistant/components/sensor/moon.py @@ -4,7 +4,6 @@ Support for tracking the moon phases. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.moon/ """ -import asyncio import logging import voluptuous as vol @@ -26,8 +25,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 Moon sensor.""" name = config.get(CONF_NAME) @@ -71,8 +70,7 @@ class MoonSensor(Entity): """Icon to use in the frontend, if any.""" return ICON - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get the time and updates the states.""" from astral import Astral diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index b19f5721e4f..997fd312a6a 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -4,10 +4,10 @@ Support for MQTT sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.mqtt/ """ -import asyncio import logging import json from datetime import timedelta +from typing import Optional import voluptuous as vol @@ -15,12 +15,14 @@ from homeassistant.core import callback from homeassistant.components.mqtt import ( CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, MqttAvailability) +from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA from homeassistant.const import ( CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, - CONF_UNIT_OF_MEASUREMENT) + CONF_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_DEVICE_CLASS) from homeassistant.helpers.entity import Entity import homeassistant.components.mqtt as mqtt import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType, ConfigType from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util @@ -28,6 +30,7 @@ _LOGGER = logging.getLogger(__name__) CONF_EXPIRE_AFTER = 'expire_after' CONF_JSON_ATTRS = 'json_attributes' +CONF_UNIQUE_ID = 'unique_id' DEFAULT_NAME = 'MQTT Sensor' DEFAULT_FORCE_UPDATE = False @@ -36,14 +39,19 @@ DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + # Integrations shouldn't never expose unique_id through configuration + # this here is an exception because MQTT is a msg transport, not a protocol + vol.Optional(CONF_UNIQUE_ID): cv.string, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up MQTT Sensor.""" if discovery_info is not None: config = PLATFORM_SCHEMA(discovery_info) @@ -59,8 +67,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_UNIT_OF_MEASUREMENT), config.get(CONF_FORCE_UPDATE), config.get(CONF_EXPIRE_AFTER), + config.get(CONF_ICON), + config.get(CONF_DEVICE_CLASS), value_template, config.get(CONF_JSON_ATTRS), + config.get(CONF_UNIQUE_ID), config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_PAYLOAD_AVAILABLE), config.get(CONF_PAYLOAD_NOT_AVAILABLE), @@ -71,8 +82,9 @@ class MqttSensor(MqttAvailability, Entity): """Representation of a sensor that can be updated using MQTT.""" def __init__(self, name, state_topic, qos, unit_of_measurement, - force_update, expire_after, value_template, - json_attributes, availability_topic, payload_available, + force_update, expire_after, icon, device_class: Optional[str], + value_template, json_attributes, unique_id: Optional[str], + availability_topic, payload_available, payload_not_available): """Initialize the sensor.""" super().__init__(availability_topic, qos, payload_available, @@ -85,14 +97,16 @@ class MqttSensor(MqttAvailability, Entity): self._force_update = force_update self._template = value_template self._expire_after = expire_after + self._icon = icon + self._device_class = device_class self._expiration_trigger = None self._json_attributes = set(json_attributes) + self._unique_id = unique_id self._attributes = None - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe to MQTT events.""" - yield from super().async_added_to_hass() + await super().async_added_to_hass() @callback def message_received(topic, payload, qos): @@ -131,8 +145,8 @@ class MqttSensor(MqttAvailability, Entity): self._state = payload self.async_schedule_update_ha_state() - yield from mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + await mqtt.async_subscribe(self.hass, self._state_topic, + message_received, self._qos) @callback def value_is_expired(self, *_): @@ -170,3 +184,18 @@ class MqttSensor(MqttAvailability, Entity): def device_state_attributes(self): """Return the state attributes.""" return self._attributes + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def device_class(self) -> Optional[str]: + """Return the device class of the sensor.""" + return self._device_class diff --git a/homeassistant/components/sensor/mychevy.py b/homeassistant/components/sensor/mychevy.py index bdbffc46ca8..ef7c7ba8608 100644 --- a/homeassistant/components/sensor/mychevy.py +++ b/homeassistant/components/sensor/mychevy.py @@ -17,14 +17,15 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify -BATTERY_SENSOR = "percent" +BATTERY_SENSOR = "batteryLevel" SENSORS = [ - EVSensorConfig("Mileage", "mileage", "miles", "mdi:speedometer"), - EVSensorConfig("Range", "range", "miles", "mdi:speedometer"), - EVSensorConfig("Charging", "charging"), - EVSensorConfig("Charge Mode", "charge_mode"), - EVSensorConfig("EVCharge", BATTERY_SENSOR, "%", "mdi:battery") + EVSensorConfig("Mileage", "totalMiles", "miles", "mdi:speedometer"), + EVSensorConfig("Electric Range", "electricRange", "miles", + "mdi:speedometer"), + EVSensorConfig("Charged By", "estimatedFullChargeBy"), + EVSensorConfig("Charge Mode", "chargeMode"), + EVSensorConfig("Battery Level", BATTERY_SENSOR, "%", "mdi:battery") ] _LOGGER = logging.getLogger(__name__) @@ -38,7 +39,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hub = hass.data[MYCHEVY_DOMAIN] sensors = [MyChevyStatus()] for sconfig in SENSORS: - sensors.append(EVSensor(hub, sconfig)) + for car in hub.cars: + sensors.append(EVSensor(hub, sconfig, car.vid)) add_devices(sensors) @@ -112,7 +114,7 @@ class EVSensor(Entity): """ - def __init__(self, connection, config): + def __init__(self, connection, config, car_vid): """Initialize sensor with car connection.""" self._conn = connection self._name = config.name @@ -120,9 +122,12 @@ class EVSensor(Entity): self._unit_of_measurement = config.unit_of_measurement self._icon = config.icon self._state = 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))) @asyncio.coroutine def async_added_to_hass(self): @@ -130,6 +135,11 @@ class EVSensor(Entity): self.hass.helpers.dispatcher.async_dispatcher_connect( UPDATE_TOPIC, self.async_update_callback) + @property + def _car(self): + """Return the car.""" + return self._conn.get_car(self._car_vid) + @property def icon(self): """Return the icon.""" @@ -145,8 +155,8 @@ class EVSensor(Entity): @callback def async_update_callback(self): """Update state.""" - if self._conn.car is not None: - self._state = getattr(self._conn.car, self._attr, None) + if self._car is not None: + self._state = getattr(self._car, self._attr, None) self.async_schedule_update_ha_state() @property diff --git a/homeassistant/components/sensor/mysensors.py b/homeassistant/components/sensor/mysensors.py index a8daf212e57..1add4157f0e 100644 --- a/homeassistant/components/sensor/mysensors.py +++ b/homeassistant/components/sensor/mysensors.py @@ -8,11 +8,38 @@ from homeassistant.components import mysensors from homeassistant.components.sensor import DOMAIN from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +SENSORS = { + 'V_TEMP': [None, 'mdi:thermometer'], + 'V_HUM': ['%', 'mdi:water-percent'], + 'V_DIMMER': ['%', 'mdi:percent'], + 'V_LIGHT_LEVEL': ['%', 'white-balance-sunny'], + 'V_DIRECTION': ['°', 'mdi:compass'], + 'V_WEIGHT': ['kg', 'mdi:weight-kilogram'], + 'V_DISTANCE': ['m', 'mdi:ruler'], + 'V_IMPEDANCE': ['ohm', None], + 'V_WATT': ['W', None], + 'V_KWH': ['kWh', None], + 'V_FLOW': ['m', None], + 'V_VOLUME': ['m³', None], + 'V_VOLTAGE': ['V', 'mdi:flash'], + 'V_CURRENT': ['A', 'mdi:flash-auto'], + 'V_PERCENTAGE': ['%', 'mdi:percent'], + 'V_LEVEL': { + 'S_SOUND': ['dB', 'mdi:volume-high'], 'S_VIBRATION': ['Hz', None], + 'S_LIGHT_LEVEL': ['lx', 'white-balance-sunny']}, + 'V_ORP': ['mV', None], + 'V_EC': ['μS/cm', None], + 'V_VAR': ['var', None], + 'V_VA': ['VA', None], +} -def setup_platform(hass, config, add_devices, discovery_info=None): + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the MySensors platform for sensors.""" mysensors.setup_mysensors_platform( - hass, DOMAIN, discovery_info, MySensorsSensor, add_devices=add_devices) + hass, DOMAIN, discovery_info, MySensorsSensor, + async_add_devices=async_add_devices) class MySensorsSensor(mysensors.MySensorsEntity): @@ -32,45 +59,30 @@ class MySensorsSensor(mysensors.MySensorsEntity): """Return the state of the device.""" return self._values.get(self.value_type) + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + _, icon = self._get_sensor_type() + return icon + @property def unit_of_measurement(self): """Return the unit of measurement of this entity.""" + set_req = self.gateway.const.SetReq + if (float(self.gateway.protocol_version) >= 1.5 and + set_req.V_UNIT_PREFIX in self._values): + return self._values[set_req.V_UNIT_PREFIX] + unit, _ = self._get_sensor_type() + return unit + + def _get_sensor_type(self): + """Return list with unit and icon of sensor type.""" pres = self.gateway.const.Presentation set_req = self.gateway.const.SetReq - unit_map = { - set_req.V_TEMP: (TEMP_CELSIUS - if self.gateway.metric else TEMP_FAHRENHEIT), - set_req.V_HUM: '%', - set_req.V_DIMMER: '%', - set_req.V_LIGHT_LEVEL: '%', - set_req.V_DIRECTION: '°', - set_req.V_WEIGHT: 'kg', - set_req.V_DISTANCE: 'm', - set_req.V_IMPEDANCE: 'ohm', - set_req.V_WATT: 'W', - set_req.V_KWH: 'kWh', - set_req.V_FLOW: 'm', - set_req.V_VOLUME: 'm³', - set_req.V_VOLTAGE: 'V', - set_req.V_CURRENT: 'A', - } - if float(self.gateway.protocol_version) >= 1.5: - if set_req.V_UNIT_PREFIX in self._values: - return self._values[ - set_req.V_UNIT_PREFIX] - unit_map.update({ - set_req.V_PERCENTAGE: '%', - set_req.V_LEVEL: { - pres.S_SOUND: 'dB', pres.S_VIBRATION: 'Hz', - pres.S_LIGHT_LEVEL: 'lux'}}) - if float(self.gateway.protocol_version) >= 2.0: - unit_map.update({ - set_req.V_ORP: 'mV', - set_req.V_EC: 'μS/cm', - set_req.V_VAR: 'var', - set_req.V_VA: 'VA', - }) - unit = unit_map.get(self.value_type) - if isinstance(unit, dict): - unit = unit.get(self.child_type) - return unit + SENSORS[set_req.V_TEMP.name][0] = ( + TEMP_CELSIUS if self.gateway.metric else TEMP_FAHRENHEIT) + sensor_type = SENSORS.get(set_req(self.value_type).name, [None, None]) + if isinstance(sensor_type, dict): + sensor_type = sensor_type.get( + pres(self.child_type).name, [None, None]) + return sensor_type diff --git a/homeassistant/components/sensor/nest.py b/homeassistant/components/sensor/nest.py index e2567fdf4ca..00d18c7fe10 100644 --- a/homeassistant/components/sensor/nest.py +++ b/homeassistant/components/sensor/nest.py @@ -4,41 +4,44 @@ Support for Nest Thermostat Sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.nest/ """ -from itertools import chain import logging -from homeassistant.components.nest import DATA_NEST -from homeassistant.helpers.entity import Entity -from homeassistant.const import (TEMP_CELSIUS, TEMP_FAHRENHEIT, - CONF_MONITORED_CONDITIONS) +from homeassistant.components.nest import DATA_NEST, NestSensorDevice +from homeassistant.const import ( + TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_MONITORED_CONDITIONS, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY) DEPENDENCIES = ['nest'] -SENSOR_TYPES = ['humidity', - 'operation_mode', - 'hvac_state'] + +SENSOR_TYPES = ['humidity', 'operation_mode', 'hvac_state'] + +TEMP_SENSOR_TYPES = ['temperature', 'target'] + +PROTECT_SENSOR_TYPES = ['co_status', 'smoke_status', 'battery_health'] + +STRUCTURE_SENSOR_TYPES = ['eta'] + +_VALID_SENSOR_TYPES = SENSOR_TYPES + TEMP_SENSOR_TYPES + PROTECT_SENSOR_TYPES \ + + STRUCTURE_SENSOR_TYPES + +SENSOR_UNITS = {'humidity': '%'} + +SENSOR_DEVICE_CLASSES = {'humidity': DEVICE_CLASS_HUMIDITY} + +VARIABLE_NAME_MAPPING = {'eta': 'eta_begin', 'operation_mode': 'mode'} SENSOR_TYPES_DEPRECATED = ['last_ip', 'local_ip', - 'last_connection'] + 'last_connection', + 'battery_level'] -DEPRECATED_WEATHER_VARS = {'weather_humidity': 'humidity', - 'weather_temperature': 'temperature', - 'weather_condition': 'condition', - 'wind_speed': 'kph', - 'wind_direction': 'direction'} +DEPRECATED_WEATHER_VARS = ['weather_humidity', + 'weather_temperature', + 'weather_condition', + 'wind_speed', + 'wind_direction'] -SENSOR_UNITS = {'humidity': '%', 'temperature': '°C'} - -PROTECT_VARS = ['co_status', 'smoke_status', 'battery_health'] - -PROTECT_VARS_DEPRECATED = ['battery_level'] - -SENSOR_TEMP_TYPES = ['temperature', 'target'] - -_SENSOR_TYPES_DEPRECATED = SENSOR_TYPES_DEPRECATED \ - + list(DEPRECATED_WEATHER_VARS.keys()) + PROTECT_VARS_DEPRECATED - -_VALID_SENSOR_TYPES = SENSOR_TYPES + SENSOR_TEMP_TYPES + PROTECT_VARS +_SENSOR_TYPES_DEPRECATED = SENSOR_TYPES_DEPRECATED + DEPRECATED_WEATHER_VARS _LOGGER = logging.getLogger(__name__) @@ -68,53 +71,31 @@ def setup_platform(hass, config, add_devices, discovery_info=None): "monitored_conditions. See " "https://home-assistant.io/components/" "binary_sensor.nest/ for valid options.") - _LOGGER.error(wstr) all_sensors = [] - for structure, device in chain(nest.thermostats(), nest.smoke_co_alarms()): - sensors = [NestBasicSensor(structure, device, variable) - for variable in conditions - if variable in SENSOR_TYPES and device.is_thermostat] - sensors += [NestTempSensor(structure, device, variable) - for variable in conditions - if variable in SENSOR_TEMP_TYPES and device.is_thermostat] - sensors += [NestProtectSensor(structure, device, variable) - for variable in conditions - if variable in PROTECT_VARS and device.is_smoke_co_alarm] - all_sensors.extend(sensors) + for structure in nest.structures(): + all_sensors += [NestBasicSensor(structure, None, variable) + for variable in conditions + if variable in STRUCTURE_SENSOR_TYPES] + + for structure, device in nest.thermostats(): + all_sensors += [NestBasicSensor(structure, device, variable) + for variable in conditions + if variable in SENSOR_TYPES] + all_sensors += [NestTempSensor(structure, device, variable) + for variable in conditions + if variable in TEMP_SENSOR_TYPES] + + for structure, device in nest.smoke_co_alarms(): + all_sensors += [NestBasicSensor(structure, device, variable) + for variable in conditions + if variable in PROTECT_SENSOR_TYPES] add_devices(all_sensors, True) -class NestSensor(Entity): - """Representation of a Nest sensor.""" - - def __init__(self, structure, device, variable): - """Initialize the sensor.""" - self.structure = structure - self.device = device - self.variable = variable - - # device specific - self._location = self.device.where - self._name = "{} {}".format(self.device.name_long, - self.variable.replace("_", " ")) - self._state = None - self._unit = None - - @property - def name(self): - """Return the name of the nest, if any.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit - - -class NestBasicSensor(NestSensor): +class NestBasicSensor(NestSensorDevice): """Representation a basic Nest sensor.""" @property @@ -122,17 +103,26 @@ class NestBasicSensor(NestSensor): """Return the state of the sensor.""" return self._state + @property + def device_class(self): + """Return the device class of the sensor.""" + return SENSOR_DEVICE_CLASSES.get(self.variable) + def update(self): """Retrieve latest state.""" - self._unit = SENSOR_UNITS.get(self.variable, None) + self._unit = SENSOR_UNITS.get(self.variable) - if self.variable == 'operation_mode': - self._state = getattr(self.device, "mode") + if self.variable in VARIABLE_NAME_MAPPING: + self._state = getattr(self.device, + VARIABLE_NAME_MAPPING[self.variable]) + elif self.variable in PROTECT_SENSOR_TYPES: + # keep backward compatibility + self._state = getattr(self.device, self.variable).capitalize() else: self._state = getattr(self.device, self.variable) -class NestTempSensor(NestSensor): +class NestTempSensor(NestSensorDevice): """Representation of a Nest Temperature sensor.""" @property @@ -140,6 +130,11 @@ class NestTempSensor(NestSensor): """Return the state of the sensor.""" return self._state + @property + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_TEMPERATURE + def update(self): """Retrieve latest state.""" if self.device.temperature_scale == 'C': @@ -156,16 +151,3 @@ class NestTempSensor(NestSensor): self._state = "%s-%s" % (int(low), int(high)) else: self._state = round(temp, 1) - - -class NestProtectSensor(NestSensor): - """Return the state of nest protect.""" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - def update(self): - """Retrieve latest state.""" - self._state = getattr(self.device, self.variable).capitalize() diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index 4dddaf45aa4..f09e1d4f395 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -10,10 +10,11 @@ from datetime import timedelta import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN +from homeassistant.const import ( + TEMP_CELSIUS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + STATE_UNKNOWN) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -from homeassistant.loader import get_component import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -27,28 +28,29 @@ DEPENDENCIES = ['netatmo'] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=600) SENSOR_TYPES = { - 'temperature': ['Temperature', TEMP_CELSIUS, 'mdi:thermometer'], - 'co2': ['CO2', 'ppm', 'mdi:cloud'], - 'pressure': ['Pressure', 'mbar', 'mdi:gauge'], - 'noise': ['Noise', 'dB', 'mdi:volume-high'], - 'humidity': ['Humidity', '%', 'mdi:water-percent'], - 'rain': ['Rain', 'mm', 'mdi:weather-rainy'], - 'sum_rain_1': ['sum_rain_1', 'mm', 'mdi:weather-rainy'], - 'sum_rain_24': ['sum_rain_24', 'mm', 'mdi:weather-rainy'], - 'battery_vp': ['Battery', '', 'mdi:battery'], - 'battery_lvl': ['Battery_lvl', '', 'mdi:battery'], - 'min_temp': ['Min Temp.', TEMP_CELSIUS, 'mdi:thermometer'], - 'max_temp': ['Max Temp.', TEMP_CELSIUS, 'mdi:thermometer'], - 'windangle': ['Angle', '', 'mdi:compass'], - 'windangle_value': ['Angle Value', 'º', 'mdi:compass'], - 'windstrength': ['Strength', 'km/h', 'mdi:weather-windy'], - 'gustangle': ['Gust Angle', '', 'mdi:compass'], - 'gustangle_value': ['Gust Angle Value', 'º', 'mdi:compass'], - 'guststrength': ['Gust Strength', 'km/h', 'mdi:weather-windy'], - 'rf_status': ['Radio', '', 'mdi:signal'], - 'rf_status_lvl': ['Radio_lvl', '', 'mdi:signal'], - 'wifi_status': ['Wifi', '', 'mdi:wifi'], - 'wifi_status_lvl': ['Wifi_lvl', 'dBm', 'mdi:wifi'] + 'temperature': ['Temperature', TEMP_CELSIUS, None, + DEVICE_CLASS_TEMPERATURE], + 'co2': ['CO2', 'ppm', 'mdi:cloud', None], + 'pressure': ['Pressure', 'mbar', 'mdi:gauge', None], + 'noise': ['Noise', 'dB', 'mdi:volume-high', None], + 'humidity': ['Humidity', '%', None, DEVICE_CLASS_HUMIDITY], + 'rain': ['Rain', 'mm', 'mdi:weather-rainy', None], + 'sum_rain_1': ['sum_rain_1', 'mm', 'mdi:weather-rainy', None], + 'sum_rain_24': ['sum_rain_24', 'mm', 'mdi:weather-rainy', None], + 'battery_vp': ['Battery', '', 'mdi:battery', None], + 'battery_lvl': ['Battery_lvl', '', 'mdi:battery', None], + 'min_temp': ['Min Temp.', TEMP_CELSIUS, 'mdi:thermometer', None], + 'max_temp': ['Max Temp.', TEMP_CELSIUS, 'mdi:thermometer', None], + 'windangle': ['Angle', '', 'mdi:compass', None], + 'windangle_value': ['Angle Value', 'º', 'mdi:compass', None], + 'windstrength': ['Strength', 'km/h', 'mdi:weather-windy', None], + 'gustangle': ['Gust Angle', '', 'mdi:compass', None], + 'gustangle_value': ['Gust Angle Value', 'º', 'mdi:compass', None], + 'guststrength': ['Gust Strength', 'km/h', 'mdi:weather-windy', None], + 'rf_status': ['Radio', '', 'mdi:signal', None], + 'rf_status_lvl': ['Radio_lvl', '', 'mdi:signal', None], + 'wifi_status': ['Wifi', '', 'mdi:wifi', None], + 'wifi_status_lvl': ['Wifi_lvl', 'dBm', 'mdi:wifi', None] } MODULE_SCHEMA = vol.Schema({ @@ -64,7 +66,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the available Netatmo weather sensors.""" - netatmo = get_component('netatmo') + netatmo = hass.components.netatmo data = NetAtmoData(netatmo.NETATMO_AUTH, config.get(CONF_STATION, None)) dev = [] @@ -107,7 +109,9 @@ class NetAtmoSensor(Entity): self.module_name = module_name self.type = sensor_type self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._device_class = SENSOR_TYPES[self.type][3] + self._icon = SENSOR_TYPES[self.type][2] + self._unit_of_measurement = SENSOR_TYPES[self.type][1] module_id = self.netatmo_data.\ station_data.moduleByName(module=module_name)['_id'] self.module_id = module_id[1] @@ -120,7 +124,12 @@ class NetAtmoSensor(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] + return self._icon + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class @property def state(self): diff --git a/homeassistant/components/sensor/netdata.py b/homeassistant/components/sensor/netdata.py index 0d2a542c7bb..2d08159967c 100644 --- a/homeassistant/components/sensor/netdata.py +++ b/homeassistant/components/sensor/netdata.py @@ -4,154 +4,152 @@ Support gathering system information of hosts which are running netdata. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.netdata/ """ -import logging from datetime import timedelta -from urllib.parse import urlsplit +import logging -import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_PORT, CONF_NAME, CONF_RESOURCES) + CONF_HOST, CONF_ICON, CONF_NAME, CONF_PORT, CONF_RESOURCES) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['netdata==0.1.2'] _LOGGER = logging.getLogger(__name__) -_RESOURCE = 'api/v1' -_REALTIME = 'before=0&after=-1&options=seconds' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + +CONF_ELEMENT = 'element' DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'Netdata' -DEFAULT_PORT = '19999' +DEFAULT_PORT = 19999 -SCAN_INTERVAL = timedelta(minutes=1) +DEFAULT_ICON = 'mdi:desktop-classic' -SENSOR_TYPES = { - 'memory_free': ['RAM Free', 'MiB', 'system.ram', 'free', 1], - 'memory_used': ['RAM Used', 'MiB', 'system.ram', 'used', 1], - 'memory_cached': ['RAM Cached', 'MiB', 'system.ram', 'cached', 1], - 'memory_buffers': ['RAM Buffers', 'MiB', 'system.ram', 'buffers', 1], - 'swap_free': ['Swap Free', 'MiB', 'system.swap', 'free', 1], - 'swap_used': ['Swap Used', 'MiB', 'system.swap', 'used', 1], - 'processes_running': ['Processes Running', 'Count', 'system.processes', - 'running', 0], - 'processes_blocked': ['Processes Blocked', 'Count', 'system.processes', - 'blocked', 0], - 'system_load': ['System Load', '15 min', 'system.load', 'load15', 2], - 'system_io_in': ['System IO In', 'Count', 'system.io', 'in', 0], - 'system_io_out': ['System IO Out', 'Count', 'system.io', 'out', 0], - 'ipv4_in': ['IPv4 In', 'kb/s', 'system.ipv4', 'received', 0], - 'ipv4_out': ['IPv4 Out', 'kb/s', 'system.ipv4', 'sent', 0], - 'disk_free': ['Disk Free', 'GiB', 'disk_space._', 'avail', 2], - 'cpu_iowait': ['CPU IOWait', '%', 'system.cpu', 'iowait', 1], - 'cpu_user': ['CPU User', '%', 'system.cpu', 'user', 1], - 'cpu_system': ['CPU System', '%', 'system.cpu', 'system', 1], - 'cpu_softirq': ['CPU SoftIRQ', '%', 'system.cpu', 'softirq', 1], - 'cpu_guest': ['CPU Guest', '%', 'system.cpu', 'guest', 1], - 'uptime': ['Uptime', 's', 'system.uptime', 'uptime', 0], - 'packets_received': ['Packets Received', 'packets/s', 'ipv4.packets', - 'received', 0], - 'packets_sent': ['Packets Sent', 'packets/s', 'ipv4.packets', - 'sent', 0], - 'connections': ['Active Connections', 'Count', - 'netfilter.conntrack_sockets', 'connections', 0] -} +RESOURCE_SCHEMA = vol.Any({ + vol.Required(CONF_ELEMENT): cv.string, + vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.icon, + vol.Optional(CONF_NAME): cv.string, +}) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_RESOURCES, default=['memory_free']): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Required(CONF_RESOURCES): vol.Schema({cv.string: RESOURCE_SCHEMA}), }) -# pylint: disable=unused-variable -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the Netdata sensor.""" + from netdata import Netdata + name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) - url = 'http://{}:{}'.format(host, port) - data_url = '{}/{}/data?chart='.format(url, _RESOURCE) resources = config.get(CONF_RESOURCES) - values = {} - for key, value in sorted(SENSOR_TYPES.items()): - if key in resources: - values.setdefault(value[2], []).append(key) + session = async_get_clientsession(hass) + netdata = NetdataData(Netdata(host, hass.loop, session, port=port)) + await netdata.async_update() + + if netdata.api.metrics is None: + raise PlatformNotReady dev = [] - for chart in values: - rest_url = '{}{}&{}'.format(data_url, chart, _REALTIME) - rest = NetdataData(rest_url) - rest.update() - for sensor_type in values[chart]: - dev.append(NetdataSensor(rest, name, sensor_type)) + for entry, data in resources.items(): + sensor = entry + element = data[CONF_ELEMENT] + sensor_name = icon = None + try: + resource_data = netdata.api.metrics[sensor] + unit = '%' if resource_data['units'] == 'percentage' else \ + resource_data['units'] + if data is not None: + sensor_name = data.get(CONF_NAME) + icon = data.get(CONF_ICON) + except KeyError: + _LOGGER.error("Sensor is not available: %s", sensor) + continue - add_devices(dev, True) + dev.append(NetdataSensor( + netdata, name, sensor, sensor_name, element, icon, unit)) + + async_add_devices(dev, True) class NetdataSensor(Entity): """Implementation of a Netdata sensor.""" - def __init__(self, rest, name, sensor_type): + def __init__( + self, netdata, name, sensor, sensor_name, element, icon, unit): """Initialize the Netdata sensor.""" - self.rest = rest - self.type = sensor_type - self._name = '{} {}'.format(name, SENSOR_TYPES[self.type][0]) - self._precision = SENSOR_TYPES[self.type][4] - self._unit_of_measurement = SENSOR_TYPES[self.type][1] + self.netdata = netdata + self._state = None + self._sensor = sensor + self._element = element + self._sensor_name = self._sensor if sensor_name is None else \ + sensor_name + self._name = name + self._icon = icon + self._unit_of_measurement = unit @property def name(self): """Return the name of the sensor.""" - return self._name + return '{} {}'.format(self._name, self._sensor_name) @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + @property def state(self): """Return the state of the resources.""" - value = self.rest.data - - if value is not None: - netdata_id = SENSOR_TYPES[self.type][3] - if netdata_id in value: - return "{0:.{1}f}".format(value[netdata_id], self._precision) - return None + return self._state @property def available(self): """Could the resource be accessed during the last update call.""" - return self.rest.available + return self.netdata.available - def update(self): + async def async_update(self): """Get the latest data from Netdata REST API.""" - self.rest.update() + await self.netdata.async_update() + resource_data = self.netdata.api.metrics.get(self._sensor) + self._state = round( + resource_data['dimensions'][self._element]['value'], 2) class NetdataData(object): """The class for handling the data retrieval.""" - def __init__(self, resource): + def __init__(self, api): """Initialize the data object.""" - self._resource = resource - self.data = None + self.api = api self.available = True - def update(self): + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): """Get the latest data from the Netdata REST API.""" + from netdata.exceptions import NetdataError + try: - response = requests.get(self._resource, timeout=5) - det = response.json() - self.data = {k: v for k, v in zip(det['labels'], det['data'][0])} + await self.api.get_allmetrics() self.available = True - except requests.exceptions.ConnectionError: - _LOGGER.error("Connection error: %s", urlsplit(self._resource)[1]) - self.data = None + except NetdataError: + _LOGGER.error("Unable to retrieve data from Netdata") self.available = False diff --git a/homeassistant/components/sensor/nut.py b/homeassistant/components/sensor/nut.py index e0d5b7250e9..bf440728a2e 100644 --- a/homeassistant/components/sensor/nut.py +++ b/homeassistant/components/sensor/nut.py @@ -14,6 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, TEMP_CELSIUS, CONF_RESOURCES, CONF_ALIAS, ATTR_STATE, STATE_UNKNOWN) +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -26,10 +27,12 @@ DEFAULT_HOST = 'localhost' DEFAULT_PORT = 3493 KEY_STATUS = 'ups.status' +KEY_STATUS_DISPLAY = 'ups.status.display' MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) SENSOR_TYPES = { + 'ups.status.display': ['Status', '', 'mdi:information-outline'], 'ups.status': ['Status Data', '', 'mdi:information-outline'], 'ups.alarm': ['Alarms', '', 'mdi:alarm'], 'ups.time': ['Internal Time', '', 'mdi:calendar-clock'], @@ -113,6 +116,7 @@ STATE_TYPES = { 'HB': 'High Battery', 'RB': 'Battery Needs Replaced', 'CHRG': 'Battery Charging', + 'DISCHRG': 'Battery Discharging', 'BYPASS': 'Bypass Active', 'CAL': 'Runtime Calibration', 'OFF': 'Offline', @@ -129,7 +133,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ALIAS): cv.string, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, - vol.Required(CONF_RESOURCES, default=[]): + vol.Required(CONF_RESOURCES): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) @@ -147,7 +151,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if data.status is None: _LOGGER.error("NUT Sensor has no data, unable to setup") - return False + raise PlatformNotReady _LOGGER.debug('NUT Sensors Available: %s', data.status) @@ -156,7 +160,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for resource in config[CONF_RESOURCES]: sensor_type = resource.lower() - if sensor_type in data.status: + # Display status is a special case that falls back to the status value + # of the UPS instead. + if sensor_type in data.status or (sensor_type == KEY_STATUS_DISPLAY + and KEY_STATUS in data.status): entities.append(NUTSensor(name, data, sensor_type)) else: _LOGGER.warning( @@ -168,7 +175,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except data.pynuterror as err: _LOGGER.error("Failure while testing NUT status retrieval. " "Cannot continue setup: %s", err) - return False + raise PlatformNotReady add_entities(entities, True) @@ -208,11 +215,11 @@ class NUTSensor(Entity): def device_state_attributes(self): """Return the sensor attributes.""" attr = dict() - attr[ATTR_STATE] = self.opp_state() + attr[ATTR_STATE] = self.display_state() return attr - def opp_state(self): - """Return UPS operating state.""" + def display_state(self): + """Return UPS display state.""" if self._data.status is None: return STATE_TYPES['OFF'] else: @@ -229,7 +236,11 @@ class NUTSensor(Entity): self._state = None return - if self.type not in self._data.status: + # In case of the display status sensor, keep a human-readable form + # as the sensor state. + if self.type == KEY_STATUS_DISPLAY: + self._state = self.display_state() + elif self.type not in self._data.status: self._state = None else: self._state = self._data.status[self.type] @@ -287,5 +298,5 @@ class PyNUTData(object): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self, **kwargs): - """Fetch the latest status from APCUPSd.""" + """Fetch the latest status from NUT.""" self._status = self._get_status() diff --git a/homeassistant/components/sensor/onewire.py b/homeassistant/components/sensor/onewire.py index 8a07d3484d5..43105d54e38 100644 --- a/homeassistant/components/sensor/onewire.py +++ b/homeassistant/components/sensor/onewire.py @@ -28,7 +28,8 @@ DEVICE_SENSORS = {'10': {'temperature': 'temperature'}, '22': {'temperature': 'temperature'}, '26': {'temperature': 'temperature', 'humidity': 'humidity', - 'pressure': 'B1-R1-A/pressure'}, + 'pressure': 'B1-R1-A/pressure', + 'illuminance': 'S3-R1-A/illuminance'}, '28': {'temperature': 'temperature'}, '3B': {'temperature': 'temperature'}, '42': {'temperature': 'temperature'}} @@ -37,6 +38,7 @@ SENSOR_TYPES = { 'temperature': ['temperature', TEMP_CELSIUS], 'humidity': ['humidity', '%'], 'pressure': ['pressure', 'mb'], + 'illuminance': ['illuminance', 'lux'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/sensor/pi_hole.py b/homeassistant/components/sensor/pi_hole.py index 027c12569a6..8e8c784e68b 100644 --- a/homeassistant/components/sensor/pi_hole.py +++ b/homeassistant/components/sensor/pi_hole.py @@ -1,23 +1,26 @@ """ -Support for getting statistical data from a Pi-Hole system. +Support for getting statistical data from a Pi-hole system. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.pi_hole/ """ -import logging -import json from datetime import timedelta +import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS) + CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SSL, CONF_VERIFY_SSL) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['pihole==0.1.2'] _LOGGER = logging.getLogger(__name__) -_ENDPOINT = '/api.php' ATTR_BLOCKED_DOMAINS = 'domains_blocked' ATTR_PERCENTAGE_TODAY = 'percentage_today' @@ -32,25 +35,27 @@ DEFAULT_NAME = 'Pi-Hole' DEFAULT_SSL = False DEFAULT_VERIFY_SSL = True -SCAN_INTERVAL = timedelta(minutes=5) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) MONITORED_CONDITIONS = { - 'dns_queries_today': ['DNS Queries Today', - 'queries', 'mdi:comment-question-outline'], - 'ads_blocked_today': ['Ads Blocked Today', - 'ads', 'mdi:close-octagon-outline'], - 'ads_percentage_today': ['Ads Percentage Blocked Today', - '%', 'mdi:close-octagon-outline'], - 'domains_being_blocked': ['Domains Blocked', - 'domains', 'mdi:block-helper'], - 'queries_cached': ['DNS Queries Cached', - 'queries', 'mdi:comment-question-outline'], - 'queries_forwarded': ['DNS Queries Forwarded', - 'queries', 'mdi:comment-question-outline'], - 'unique_clients': ['DNS Unique Clients', - 'clients', 'mdi:account-outline'], - 'unique_domains': ['DNS Unique Domains', - 'domains', 'mdi:domain'], + 'ads_blocked_today': + ['Ads Blocked Today', 'ads', 'mdi:close-octagon-outline'], + 'ads_percentage_today': + ['Ads Percentage Blocked Today', '%', 'mdi:close-octagon-outline'], + 'clients_ever_seen': + ['Seen Clients', 'clients', 'mdi:account-outline'], + 'dns_queries_today': + ['DNS Queries Today', 'queries', 'mdi:comment-question-outline'], + 'domains_being_blocked': + ['Domains Blocked', 'domains', 'mdi:block-helper'], + 'queries_cached': + ['DNS Queries Cached', 'queries', 'mdi:comment-question-outline'], + 'queries_forwarded': + ['DNS Queries Forwarded', 'queries', 'mdi:comment-question-outline'], + 'unique_clients': + ['DNS Unique Clients', 'clients', 'mdi:account-outline'], + 'unique_domains': + ['DNS Unique Domains', 'domains', 'mdi:domain'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -65,100 +70,105 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Pi-Hole sensor.""" +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the Pi-hole sensor.""" + from pihole import PiHole + name = config.get(CONF_NAME) host = config.get(CONF_HOST) - use_ssl = config.get(CONF_SSL) + use_tls = config.get(CONF_SSL) location = config.get(CONF_LOCATION) - verify_ssl = config.get(CONF_VERIFY_SSL) + verify_tls = config.get(CONF_VERIFY_SSL) - api = PiHoleAPI('{}/{}'.format(host, location), use_ssl, verify_ssl) + session = async_get_clientsession(hass) + pi_hole = PiHoleData(PiHole( + host, hass.loop, session, location=location, tls=use_tls, + verify_tls=verify_tls)) - sensors = [PiHoleSensor(hass, api, name, condition) + await pi_hole.async_update() + + if pi_hole.api.data is None: + raise PlatformNotReady + + sensors = [PiHoleSensor(pi_hole, name, condition) for condition in config[CONF_MONITORED_CONDITIONS]] - add_devices(sensors, True) + async_add_devices(sensors, True) class PiHoleSensor(Entity): - """Representation of a Pi-Hole sensor.""" + """Representation of a Pi-hole sensor.""" - def __init__(self, hass, api, name, variable): - """Initialize a Pi-Hole sensor.""" - self._hass = hass - self._api = api + def __init__(self, pi_hole, name, condition): + """Initialize a Pi-hole sensor.""" + self.pi_hole = pi_hole self._name = name - self._var_id = variable + self._condition = condition - variable_info = MONITORED_CONDITIONS[variable] - self._var_name = variable_info[0] - self._var_units = variable_info[1] - self._var_icon = variable_info[2] + variable_info = MONITORED_CONDITIONS[condition] + self._condition_name = variable_info[0] + self._unit_of_measurement = variable_info[1] + self._icon = variable_info[2] + self.data = {} @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._name, self._var_name) + return "{} {}".format(self._name, self._condition_name) @property def icon(self): """Icon to use in the frontend, if any.""" - return self._var_icon + return self._icon @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return self._var_units + return self._unit_of_measurement - # pylint: disable=no-member @property def state(self): """Return the state of the device.""" try: - return round(self._api.data[self._var_id], 2) + return round(self.data[self._condition], 2) except TypeError: - return self._api.data[self._var_id] + return self.data[self._condition] - # pylint: disable=no-member @property def device_state_attributes(self): """Return the state attributes of the Pi-Hole.""" return { - ATTR_BLOCKED_DOMAINS: self._api.data['domains_being_blocked'], + ATTR_BLOCKED_DOMAINS: self.data['domains_being_blocked'], } @property def available(self): """Could the device be accessed during the last update call.""" - return self._api.available + return self.pi_hole.available - def update(self): - """Get the latest data from the Pi-Hole API.""" - self._api.update() + async def async_update(self): + """Get the latest data from the Pi-hole API.""" + await self.pi_hole.async_update() + self.data = self.pi_hole.api.data -class PiHoleAPI(object): +class PiHoleData(object): """Get the latest data and update the states.""" - def __init__(self, host, use_ssl, verify_ssl): + def __init__(self, api): """Initialize the data object.""" - from homeassistant.components.sensor.rest import RestData - - uri_scheme = 'https://' if use_ssl else 'http://' - resource = "{}{}{}".format(uri_scheme, host, _ENDPOINT) - - self._rest = RestData('GET', resource, None, None, None, verify_ssl) - self.data = None + self.api = api self.available = True - self.update() - def update(self): - """Get the latest data from the Pi-Hole.""" + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Get the latest data from the Pi-hole.""" + from pihole.exceptions import PiHoleError + try: - self._rest.update() - self.data = json.loads(self._rest.data) + await self.api.get_data() self.available = True - except TypeError: - _LOGGER.error("Unable to fetch data from Pi-Hole") + except PiHoleError: + _LOGGER.error("Unable to fetch data from Pi-hole") self.available = False diff --git a/homeassistant/components/sensor/plex.py b/homeassistant/components/sensor/plex.py index 87af51d2bbd..b61e1bce0da 100644 --- a/homeassistant/components/sensor/plex.py +++ b/homeassistant/components/sensor/plex.py @@ -115,9 +115,41 @@ class PlexSensor(Entity): sessions = self._server.sessions() now_playing = [] for sess in sessions: - user = sess.usernames[0] if sess.usernames is not None else "" - title = sess.title if sess.title is not None else "" - year = sess.year if sess.year is not None else "" - now_playing.append((user, "{0} ({1})".format(title, year))) + user = sess.usernames[0] + device = sess.players[0].title + now_playing_user = "{0} - {1}".format(user, device) + now_playing_title = "" + + if sess.TYPE == 'episode': + # example: + # "Supernatural (2005) - S01 · E13 - Route 666" + season_title = sess.grandparentTitle + if sess.show().year is not None: + season_title += " ({0})".format(sess.show().year) + season_episode = "S{0}".format(sess.parentIndex) + if sess.index is not None: + season_episode += " · E{0}".format(sess.index) + episode_title = sess.title + now_playing_title = "{0} - {1} - {2}".format(season_title, + season_episode, + episode_title) + elif sess.TYPE == 'track': + # example: + # "Billy Talent - Afraid of Heights - Afraid of Heights" + track_artist = sess.grandparentTitle + track_album = sess.parentTitle + track_title = sess.title + now_playing_title = "{0} - {1} - {2}".format(track_artist, + track_album, + track_title) + else: + # example: + # "picture_of_last_summer_camp (2015)" + # "The Incredible Hulk (2008)" + now_playing_title = sess.title + if sess.year is not None: + now_playing_title += " ({0})".format(sess.year) + + now_playing.append((now_playing_user, now_playing_title)) self._state = len(sessions) self._now_playing = now_playing diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 640e13e437a..1ef5a27cf3d 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -18,7 +18,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle, slugify -REQUIREMENTS = ['pypollencom==1.1.1'] +REQUIREMENTS = ['pypollencom==1.1.2'] _LOGGER = logging.getLogger(__name__) ATTR_ALLERGEN_GENUS = 'primary_allergen_genus' @@ -160,7 +160,7 @@ class BaseSensor(Entity): def __init__(self, data, data_params, name, icon, unique_id): """Initialize the sensor.""" - self._attrs = {} + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._icon = icon self._name = name self._data_params = data_params @@ -172,7 +172,6 @@ class BaseSensor(Entity): @property def device_state_attributes(self): """Return the device state attributes.""" - self._attrs.update({ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}) return self._attrs @property @@ -254,10 +253,25 @@ class AllergyIndexSensor(BaseSensor): i['label'] for i in RATING_MAPPING if i['minimum'] <= period['Index'] <= i['maximum'] ] - self._attrs[ATTR_ALLERGEN_GENUS] = period['Triggers'][0]['Genus'] - self._attrs[ATTR_ALLERGEN_NAME] = period['Triggers'][0]['Name'] - self._attrs[ATTR_ALLERGEN_TYPE] = period['Triggers'][0][ - 'PlantType'] + + for i in range(3): + index = i + 1 + try: + data = period['Triggers'][i] + self._attrs['{0}_{1}'.format( + ATTR_ALLERGEN_GENUS, index)] = data['Genus'] + self._attrs['{0}_{1}'.format( + ATTR_ALLERGEN_NAME, index)] = data['Name'] + self._attrs['{0}_{1}'.format( + ATTR_ALLERGEN_TYPE, index)] = data['PlantType'] + except IndexError: + self._attrs['{0}_{1}'.format( + ATTR_ALLERGEN_GENUS, index)] = None + self._attrs['{0}_{1}'.format( + ATTR_ALLERGEN_NAME, index)] = None + self._attrs['{0}_{1}'.format( + ATTR_ALLERGEN_TYPE, index)] = None + self._attrs[ATTR_RATING] = rating except KeyError: diff --git a/homeassistant/components/sensor/postnl.py b/homeassistant/components/sensor/postnl.py new file mode 100644 index 00000000000..63a9c1d67d5 --- /dev/null +++ b/homeassistant/components/sensor/postnl.py @@ -0,0 +1,110 @@ +""" +Sensor for PostNL packages. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.postnl/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['postnl_api==1.0.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = 'Information provided by PostNL' + +DEFAULT_NAME = 'postnl' + +ICON = 'mdi:package-variant-closed' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the PostNL sensor platform.""" + from postnl_api import PostNL_API, UnauthorizedException + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + name = config.get(CONF_NAME) + + try: + api = PostNL_API(username, password) + + except UnauthorizedException: + _LOGGER.exception("Can't connect to the PostNL webservice") + return + + add_devices([PostNLSensor(api, name)], True) + + +class PostNLSensor(Entity): + """Representation of a PostNL sensor.""" + + def __init__(self, api, name): + """Initialize the PostNL sensor.""" + self._name = name + self._attributes = None + self._state = None + self._api = api + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return 'package(s)' + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + @property + def icon(self): + """Icon to use in the frontend.""" + return ICON + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update device state.""" + shipments = self._api.get_relevant_shipments() + status_counts = {} + + for shipment in shipments: + status = shipment['status']['formatted']['short'] + status = self._api.parse_datetime(status, '%d-%m-%Y', '%H:%M') + + name = shipment['settings']['title'] + status_counts[name] = status + + self._attributes = { + ATTR_ATTRIBUTION: ATTRIBUTION, + **status_counts + } + + self._state = len(status_counts) diff --git a/homeassistant/components/sensor/qnap.py b/homeassistant/components/sensor/qnap.py index 09c9938f1c1..7dd795d8f8d 100644 --- a/homeassistant/components/sensor/qnap.py +++ b/homeassistant/components/sensor/qnap.py @@ -12,12 +12,12 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.const import ( - CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, ATTR_NAME, CONF_VERIFY_SSL, CONF_TIMEOUT, CONF_MONITORED_CONDITIONS, TEMP_CELSIUS) from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['qnapstats==0.2.4'] +REQUIREMENTS = ['qnapstats==0.2.6'] _LOGGER = logging.getLogger(__name__) @@ -29,7 +29,6 @@ ATTR_MASK = 'Mask' ATTR_MAX_SPEED = 'Max Speed' ATTR_MEMORY_SIZE = 'Memory Size' ATTR_MODEL = 'Model' -ATTR_NAME = 'Name' ATTR_PACKETS_TX = 'Packets (TX)' ATTR_PACKETS_RX = 'Packets (RX)' ATTR_PACKETS_ERR = 'Packets (Err)' @@ -352,7 +351,7 @@ class QNAPDriveSensor(QNAPSensor): return data['health'] if self.var_id == 'drive_temp': - return int(data['temp_c']) + return int(data['temp_c']) if data['temp_c'] is not None else 0 @property def name(self): diff --git a/homeassistant/components/sensor/qwikswitch.py b/homeassistant/components/sensor/qwikswitch.py new file mode 100644 index 00000000000..1497b4ad5cc --- /dev/null +++ b/homeassistant/components/sensor/qwikswitch.py @@ -0,0 +1,68 @@ +""" +Support for Qwikswitch Sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.qwikswitch/ +""" +import logging + +from homeassistant.components.qwikswitch import DOMAIN as QWIKSWITCH, QSEntity +from homeassistant.core import callback + +DEPENDENCIES = [QWIKSWITCH] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, _, add_devices, discovery_info=None): + """Add sensor from the main Qwikswitch component.""" + if discovery_info is None: + return + + qsusb = hass.data[QWIKSWITCH] + _LOGGER.debug("Setup qwikswitch.sensor %s, %s", qsusb, discovery_info) + devs = [QSSensor(sensor) for sensor in discovery_info[QWIKSWITCH]] + add_devices(devs) + + +class QSSensor(QSEntity): + """Sensor based on a Qwikswitch relay/dimmer module.""" + + _val = None + + 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, self.unit = SENSORS[sensor_type] + if isinstance(self.unit, type): + self.unit = "{}:{}".format(sensor_type, self.channel) + + @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 = val + self.async_schedule_update_ha_state() + + @property + def state(self): + """Return the value of the sensor.""" + return str(self._val) + + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + return "qs{}:{}".format(self.qsid, self.channel) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self.unit diff --git a/homeassistant/components/sensor/rainmachine.py b/homeassistant/components/sensor/rainmachine.py new file mode 100644 index 00000000000..8faf30acc38 --- /dev/null +++ b/homeassistant/components/sensor/rainmachine.py @@ -0,0 +1,88 @@ +""" +This platform provides support for sensor data from RainMachine. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.rainmachine/ +""" +import logging + +from homeassistant.components.rainmachine import ( + DATA_RAINMACHINE, DATA_UPDATE_TOPIC, SENSORS, 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] + + sensors = [] + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + name, icon, unit = SENSORS[sensor_type] + sensors.append( + RainMachineSensor(rainmachine, sensor_type, name, icon, unit)) + + add_devices(sensors, True) + + +class RainMachineSensor(RainMachineEntity): + """A sensor implementation for raincloud device.""" + + def __init__(self, rainmachine, sensor_type, name, icon, unit): + """Initialize.""" + super().__init__(rainmachine) + + self._icon = icon + self._name = name + self._sensor_type = sensor_type + self._state = None + self._unit = unit + + @property + def icon(self) -> str: + """Return the icon.""" + return self._icon + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def state(self) -> str: + """Return the name of the entity.""" + return self._state + + @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) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @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 sensor's state.""" + self._state = self.rainmachine.restrictions['global'][ + 'freezeProtectTemp'] diff --git a/homeassistant/components/sensor/random.py b/homeassistant/components/sensor/random.py index e57bbcc3955..c3ff08a5781 100644 --- a/homeassistant/components/sensor/random.py +++ b/homeassistant/components/sensor/random.py @@ -4,7 +4,6 @@ Support for showing random numbers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.random/ """ -import asyncio import logging import voluptuous as vol @@ -34,8 +33,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 number sensor.""" name = config.get(CONF_NAME) minimum = config.get(CONF_MINIMUM) @@ -84,8 +83,7 @@ class RandomSensor(Entity): ATTR_MINIMUM: self._minimum, } - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get a new number and updates the states.""" from random import randrange self._state = randrange(self._minimum, self._maximum + 1) diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 74bfaa38f02..75235bedaab 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -176,6 +176,7 @@ class RestData(object): self._request, timeout=10, verify=self._verify_ssl) self.data = response.text - except requests.exceptions.RequestException: - _LOGGER.error("Error fetching data: %s", self._request) + except requests.exceptions.RequestException as ex: + _LOGGER.error("Error fetching data: %s from %s failed with %s", + self._request, self._request.url, ex) self.data = None diff --git a/homeassistant/components/sensor/rfxtrx.py b/homeassistant/components/sensor/rfxtrx.py index 4a555905d50..a5a6eb5f07b 100644 --- a/homeassistant/components/sensor/rfxtrx.py +++ b/homeassistant/components/sensor/rfxtrx.py @@ -10,10 +10,10 @@ import voluptuous as vol import homeassistant.components.rfxtrx as rfxtrx from homeassistant.components.rfxtrx import ( - ATTR_DATA_TYPE, ATTR_FIRE_EVENT, ATTR_NAME, CONF_AUTOMATIC_ADD, - CONF_DATA_TYPE, CONF_DEVICES, CONF_FIRE_EVENT, DATA_TYPES) + ATTR_DATA_TYPE, ATTR_FIRE_EVENT, CONF_AUTOMATIC_ADD, CONF_DATA_TYPE, + CONF_DEVICES, CONF_FIRE_EVENT, DATA_TYPES) from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME +from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import slugify diff --git a/homeassistant/components/sensor/sabnzbd.py b/homeassistant/components/sensor/sabnzbd.py index 194ff71222a..185f83c9405 100644 --- a/homeassistant/components/sensor/sabnzbd.py +++ b/homeassistant/components/sensor/sabnzbd.py @@ -4,216 +4,75 @@ Support for monitoring an SABnzbd NZB client. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.sabnzbd/ """ -import asyncio import logging -from datetime import timedelta -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_HOST, CONF_API_KEY, CONF_NAME, CONF_PORT, CONF_MONITORED_VARIABLES, - CONF_SSL) +from homeassistant.components.sabnzbd import DATA_SABNZBD, \ + SIGNAL_SABNZBD_UPDATED, SENSOR_TYPES +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle -from homeassistant.util.json import load_json, save_json -import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pysabnzbd==1.0.1'] +DEPENDENCIES = ['sabnzbd'] -_CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -CONFIG_FILE = 'sabnzbd.conf' -DEFAULT_NAME = 'SABnzbd' -DEFAULT_PORT = 8080 -DEFAULT_SSL = False - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) - -SENSOR_TYPES = { - 'current_status': ['Status', None], - 'speed': ['Speed', 'MB/s'], - 'queue_size': ['Queue', 'MB'], - 'queue_remaining': ['Left', 'MB'], - 'disk_size': ['Disk', 'GB'], - 'disk_free': ['Disk Free', 'GB'], - 'queue_count': ['Queue Count', None], -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_MONITORED_VARIABLES, default=['current_status']): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, -}) - - -@asyncio.coroutine -def async_check_sabnzbd(sab_api, base_url, api_key): - """Check if we can reach SABnzbd.""" - from pysabnzbd import SabnzbdApiException - sab_api = sab_api(base_url, api_key) - - try: - yield from sab_api.check_available() - except SabnzbdApiException: - _LOGGER.error("Connection to SABnzbd API failed") - return False - return True - - -def setup_sabnzbd(base_url, apikey, name, config, - async_add_devices, sab_api): - """Set up polling from SABnzbd and sensors.""" - sab_api = sab_api(base_url, apikey) - monitored = config.get(CONF_MONITORED_VARIABLES) - async_add_devices([SabnzbdSensor(variable, sab_api, name) - for variable in monitored]) - - -@Throttle(MIN_TIME_BETWEEN_UPDATES) -async def async_update_queue(sab_api): - """ - Throttled function to update SABnzbd queue. - - This ensures that the queue info only gets updated once for all sensors - """ - await sab_api.refresh_data() - - -def request_configuration(host, name, hass, config, async_add_devices, - sab_api): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - # We got an error if this method is called while we are configuring - if host in _CONFIGURING: - configurator.notify_errors(_CONFIGURING[host], - 'Failed to register, please try again.') +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the SABnzbd sensors.""" + if discovery_info is None: return - @asyncio.coroutine - def async_configuration_callback(data): - """Handle configuration changes.""" - api_key = data.get('api_key') - if (yield from async_check_sabnzbd(sab_api, host, api_key)): - setup_sabnzbd(host, api_key, name, config, - async_add_devices, sab_api) - - def success(): - """Set up was successful.""" - conf = load_json(hass.config.path(CONFIG_FILE)) - conf[host] = {'api_key': api_key} - save_json(hass.config.path(CONFIG_FILE), conf) - req_config = _CONFIGURING.pop(host) - configurator.async_request_done(req_config) - - hass.async_add_job(success) - - _CONFIGURING[host] = configurator.async_request_config( - DEFAULT_NAME, - async_configuration_callback, - description='Enter the API Key', - submit_caption='Confirm', - fields=[{'id': 'api_key', 'name': 'API Key', 'type': ''}] - ) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the SABnzbd platform.""" - from pysabnzbd import SabnzbdApi - - if discovery_info is not None: - host = discovery_info.get(CONF_HOST) - port = discovery_info.get(CONF_PORT) - name = DEFAULT_NAME - use_ssl = discovery_info.get('properties', {}).get('https', '0') == '1' - else: - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - name = config.get(CONF_NAME, DEFAULT_NAME) - use_ssl = config.get(CONF_SSL) - - api_key = config.get(CONF_API_KEY) - - uri_scheme = 'https://' if use_ssl else 'http://' - base_url = "{}{}:{}/".format(uri_scheme, host, port) - - if not api_key: - conf = load_json(hass.config.path(CONFIG_FILE)) - if conf.get(base_url, {}).get('api_key'): - api_key = conf[base_url]['api_key'] - - if not (yield from async_check_sabnzbd(SabnzbdApi, base_url, api_key)): - request_configuration(base_url, name, hass, config, - async_add_devices, SabnzbdApi) - return - - setup_sabnzbd(base_url, api_key, name, config, - async_add_devices, SabnzbdApi) + sab_api_data = hass.data[DATA_SABNZBD] + sensors = sab_api_data.sensors + client_name = sab_api_data.name + async_add_devices([SabnzbdSensor(sensor, sab_api_data, client_name) + for sensor in sensors]) class SabnzbdSensor(Entity): """Representation of an SABnzbd sensor.""" - def __init__(self, sensor_type, sabnzbd_api, client_name): + def __init__(self, sensor_type, sabnzbd_api_data, client_name): """Initialize the sensor.""" + self._client_name = client_name + self._field_name = SENSOR_TYPES[sensor_type][2] self._name = SENSOR_TYPES[sensor_type][0] - self.sabnzbd_api = sabnzbd_api - self.type = sensor_type - self.client_name = client_name + self._sabnzbd_api = sabnzbd_api_data self._state = None + self._type = sensor_type self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + async_dispatcher_connect(self.hass, SIGNAL_SABNZBD_UPDATED, + self.update_state) + @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self.client_name, self._name) + return '{} {}'.format(self._client_name, self._name) @property def state(self): """Return the state of the sensor.""" return self._state + def should_poll(self): + """Don't poll. Will be updated by dispatcher signal.""" + return False + @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @asyncio.coroutine - def async_refresh_sabnzbd_data(self): - """Call the throttled SABnzbd refresh method.""" - from pysabnzbd import SabnzbdApiException - try: - yield from async_update_queue(self.sabnzbd_api) - except SabnzbdApiException: - _LOGGER.exception("Connection to SABnzbd API failed") - - @asyncio.coroutine - def async_update(self): + def update_state(self, args): """Get the latest data and updates the states.""" - yield from self.async_refresh_sabnzbd_data() + self._state = self._sabnzbd_api.get_queue_field(self._field_name) - if self.sabnzbd_api.queue: - if self.type == 'current_status': - self._state = self.sabnzbd_api.queue.get('status') - elif self.type == 'speed': - mb_spd = float(self.sabnzbd_api.queue.get('kbpersec')) / 1024 - self._state = round(mb_spd, 1) - elif self.type == 'queue_size': - self._state = self.sabnzbd_api.queue.get('mb') - elif self.type == 'queue_remaining': - self._state = self.sabnzbd_api.queue.get('mbleft') - elif self.type == 'disk_size': - self._state = self.sabnzbd_api.queue.get('diskspacetotal1') - elif self.type == 'disk_free': - self._state = self.sabnzbd_api.queue.get('diskspace1') - elif self.type == 'queue_count': - self._state = self.sabnzbd_api.queue.get('noofslots_total') - else: - self._state = 'Unknown' + if self._type == 'speed': + self._state = round(float(self._state) / 1024, 1) + elif 'size' in self._type: + self._state = round(float(self._state), 2) + + self.schedule_update_ha_state() diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index 720158e1029..bc3e127508b 100644 --- a/homeassistant/components/sensor/shodan.py +++ b/homeassistant/components/sensor/shodan.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['shodan==1.7.7'] +REQUIREMENTS = ['shodan==1.8.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/sht31.py b/homeassistant/components/sensor/sht31.py new file mode 100644 index 00000000000..e1a7f3c9e5f --- /dev/null +++ b/homeassistant/components/sensor/sht31.py @@ -0,0 +1,152 @@ +""" +Support for Sensirion SHT31 temperature and humidity sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sht31/ +""" + +from datetime import timedelta +import logging +import math + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + TEMP_CELSIUS, CONF_NAME, CONF_MONITORED_CONDITIONS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.temperature import display_temp +from homeassistant.const import PRECISION_TENTHS +from homeassistant.util import Throttle + + +REQUIREMENTS = ['Adafruit-GPIO==1.0.3', + 'Adafruit-SHT31==1.0.2'] + +_LOGGER = logging.getLogger(__name__) + +CONF_I2C_ADDRESS = 'i2c_address' + +DEFAULT_NAME = 'SHT31' +DEFAULT_I2C_ADDRESS = 0x44 + +SENSOR_TEMPERATURE = 'temperature' +SENSOR_HUMIDITY = 'humidity' +SENSOR_TYPES = (SENSOR_TEMPERATURE, SENSOR_HUMIDITY) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): + vol.All(vol.Coerce(int), vol.Range(min=0x44, max=0x45)), + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the sensor platform.""" + from Adafruit_SHT31 import SHT31 + + i2c_address = config.get(CONF_I2C_ADDRESS) + sensor = SHT31(address=i2c_address) + + try: + if sensor.read_status() is None: + raise ValueError("CRC error while reading SHT31 status") + except (OSError, ValueError): + _LOGGER.error( + "SHT31 sensor not detected at address %s", hex(i2c_address)) + return + sensor_client = SHTClient(sensor) + + sensor_classes = { + SENSOR_TEMPERATURE: SHTSensorTemperature, + SENSOR_HUMIDITY: SHTSensorHumidity + } + + devs = [] + for sensor_type, sensor_class in sensor_classes.items(): + name = "{} {}".format(config.get(CONF_NAME), sensor_type.capitalize()) + devs.append(sensor_class(sensor_client, name)) + + add_devices(devs) + + +class SHTClient(object): + """Get the latest data from the SHT sensor.""" + + def __init__(self, adafruit_sht): + """Initialize the sensor.""" + self.adafruit_sht = adafruit_sht + self.temperature = None + self.humidity = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the SHT sensor.""" + temperature, humidity = self.adafruit_sht.read_temperature_humidity() + if math.isnan(temperature) or math.isnan(humidity): + _LOGGER.warning("Bad sample from sensor SHT31") + return + self.temperature = temperature + self.humidity = humidity + + +class SHTSensor(Entity): + """An abstract SHTSensor, can be either temperature or humidity.""" + + def __init__(self, sensor, name): + """Initialize the sensor.""" + self._sensor = sensor + self._name = name + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Fetch temperature and humidity from the sensor.""" + self._sensor.update() + + +class SHTSensorTemperature(SHTSensor): + """Representation of a temperature sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.hass.config.units.temperature_unit + + def update(self): + """Fetch temperature from the sensor.""" + super().update() + temp_celsius = self._sensor.temperature + if temp_celsius is not None: + self._state = display_temp(self.hass, temp_celsius, + TEMP_CELSIUS, PRECISION_TENTHS) + + +class SHTSensorHumidity(SHTSensor): + """Representation of a humidity sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return '%' + + def update(self): + """Fetch humidity from the sensor.""" + super().update() + humidity = self._sensor.humidity + if humidity is not None: + self._state = round(humidity) diff --git a/homeassistant/components/sensor/sigfox.py b/homeassistant/components/sensor/sigfox.py new file mode 100644 index 00000000000..da8f3fcc639 --- /dev/null +++ b/homeassistant/components/sensor/sigfox.py @@ -0,0 +1,161 @@ +""" +Sensor for SigFox devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sigfox/ +""" +import logging +import datetime +import json +from urllib.parse import urljoin + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(seconds=30) +API_URL = 'https://backend.sigfox.com/api/' +CONF_API_LOGIN = 'api_login' +CONF_API_PASSWORD = 'api_password' +DEFAULT_NAME = 'sigfox' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_LOGIN): cv.string, + vol.Required(CONF_API_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the sigfox sensor.""" + api_login = config[CONF_API_LOGIN] + api_password = config[CONF_API_PASSWORD] + name = config[CONF_NAME] + try: + sigfox = SigfoxAPI(api_login, api_password) + except ValueError: + return False + auth = sigfox.auth + devices = sigfox.devices + + sensors = [] + for device in devices: + sensors.append(SigfoxDevice(device, auth, name)) + add_devices(sensors, True) + + +def epoch_to_datetime(epoch_time): + """Take an ms since epoch and return datetime string.""" + return datetime.datetime.fromtimestamp(epoch_time).isoformat() + + +class SigfoxAPI(object): + """Class for interacting with the SigFox API.""" + + def __init__(self, api_login, api_password): + """Initialise the API object.""" + self._auth = requests.auth.HTTPBasicAuth(api_login, api_password) + if self.check_credentials(): + device_types = self.get_device_types() + self._devices = self.get_devices(device_types) + + def check_credentials(self): + """Check API credentials are valid.""" + url = urljoin(API_URL, 'devicetypes') + response = requests.get(url, auth=self._auth, timeout=10) + if response.status_code != 200: + if response.status_code == 401: + _LOGGER.error( + "Invalid credentials for Sigfox API") + else: + _LOGGER.error( + "Unable to login to Sigfox API, error code %s", str( + response.status_code)) + raise ValueError('Sigfox component not setup') + return True + + def get_device_types(self): + """Get a list of device types.""" + url = urljoin(API_URL, 'devicetypes') + response = requests.get(url, auth=self._auth, timeout=10) + device_types = [] + for device in json.loads(response.text)['data']: + device_types.append(device['id']) + return device_types + + def get_devices(self, device_types): + """Get the device_id of each device registered.""" + devices = [] + for unique_type in device_types: + location_url = 'devicetypes/{}/devices'.format(unique_type) + url = urljoin(API_URL, location_url) + response = requests.get(url, auth=self._auth, timeout=10) + devices_data = json.loads(response.text)['data'] + for device in devices_data: + devices.append(device['id']) + return devices + + @property + def auth(self): + """Return the API authentification.""" + return self._auth + + @property + def devices(self): + """Return the list of device_id.""" + return self._devices + + +class SigfoxDevice(Entity): + """Class for single sigfox device.""" + + def __init__(self, device_id, auth, name): + """Initialise the device object.""" + self._device_id = device_id + self._auth = auth + self._message_data = {} + self._name = '{}_{}'.format(name, device_id) + self._state = None + + def get_last_message(self): + """Return the last message from a device.""" + device_url = 'devices/{}/messages?limit=1'.format(self._device_id) + url = urljoin(API_URL, device_url) + response = requests.get(url, auth=self._auth, timeout=10) + data = json.loads(response.text)['data'][0] + payload = bytes.fromhex(data['data']).decode('utf-8') + lat = data['rinfos'][0]['lat'] + lng = data['rinfos'][0]['lng'] + snr = data['snr'] + epoch_time = data['time'] + return {'lat': lat, + 'lng': lng, + 'payload': payload, + 'snr': snr, + 'time': epoch_to_datetime(epoch_time)} + + def update(self): + """Fetch the latest device message.""" + self._message_data = self.get_last_message() + self._state = self._message_data['payload'] + + @property + def name(self): + """Return the HA name of the sensor.""" + return self._name + + @property + def state(self): + """Return the payload of the last message.""" + return self._state + + @property + def device_state_attributes(self): + """Return other details about the last message.""" + return self._message_data diff --git a/homeassistant/components/sensor/simulated.py b/homeassistant/components/sensor/simulated.py index 7091146e3ac..9dac0b48bc2 100644 --- a/homeassistant/components/sensor/simulated.py +++ b/homeassistant/components/sensor/simulated.py @@ -4,51 +4,48 @@ Adds a simulated sensor. For more details about this platform, refer to the documentation at https://home-assistant.io/components/sensor.simulated/ """ -import asyncio -import datetime as datetime +import logging import math from random import Random -import logging import voluptuous as vol -import homeassistant.util.dt as dt_util -from homeassistant.helpers.entity import Entity -import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_NAME from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = datetime.timedelta(seconds=30) -ICON = 'mdi:chart-line' -CONF_UNIT = 'unit' CONF_AMP = 'amplitude' +CONF_FWHM = 'spread' CONF_MEAN = 'mean' CONF_PERIOD = 'period' CONF_PHASE = 'phase' -CONF_FWHM = 'spread' CONF_SEED = 'seed' +CONF_UNIT = 'unit' -DEFAULT_NAME = 'simulated' -DEFAULT_UNIT = 'value' DEFAULT_AMP = 1 +DEFAULT_FWHM = 0 DEFAULT_MEAN = 0 +DEFAULT_NAME = 'simulated' DEFAULT_PERIOD = 60 DEFAULT_PHASE = 0 -DEFAULT_FWHM = 0 DEFAULT_SEED = 999 +DEFAULT_UNIT = 'value' +ICON = 'mdi:chart-line' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): cv.string, vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float), + vol.Optional(CONF_FWHM, default=DEFAULT_FWHM): vol.Coerce(float), vol.Optional(CONF_MEAN, default=DEFAULT_MEAN): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.positive_int, vol.Optional(CONF_PHASE, default=DEFAULT_PHASE): vol.Coerce(float), - vol.Optional(CONF_FWHM, default=DEFAULT_FWHM): vol.Coerce(float), vol.Optional(CONF_SEED, default=DEFAULT_SEED): cv.positive_int, + vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): cv.string, }) @@ -63,9 +60,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): fwhm = config.get(CONF_FWHM) seed = config.get(CONF_SEED) - sensor = SimulatedSensor( - name, unit, amp, mean, period, phase, fwhm, seed - ) + sensor = SimulatedSensor(name, unit, amp, mean, period, phase, fwhm, seed) add_devices([sensor], True) @@ -87,7 +82,7 @@ class SimulatedSensor(Entity): self._state = None def time_delta(self): - """"Return the time delta.""" + """Return the time delta.""" dt0 = self._start_time dt1 = dt_util.utcnow() return dt1 - dt0 @@ -107,8 +102,7 @@ class SimulatedSensor(Entity): noise = self._random.gauss(mu=0, sigma=fwhm) return mean + periodic + noise - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update the sensor.""" self._state = self.signal_calc() diff --git a/homeassistant/components/sensor/skybeacon.py b/homeassistant/components/sensor/skybeacon.py index eabc33312b2..61933614a74 100644 --- a/homeassistant/components/sensor/skybeacon.py +++ b/homeassistant/components/sensor/skybeacon.py @@ -10,38 +10,38 @@ from uuid import UUID import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_MAC, TEMP_CELSIUS, STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP) + CONF_MAC, CONF_NAME, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity -# REQUIREMENTS = ['pygatt==3.1.1'] +REQUIREMENTS = ['pygatt==3.2.0'] _LOGGER = logging.getLogger(__name__) -CONNECT_LOCK = threading.Lock() - ATTR_DEVICE = 'device' ATTR_MODEL = 'model' +BLE_TEMP_HANDLE = 0x24 +BLE_TEMP_UUID = '0000ff92-0000-1000-8000-00805f9b34fb' + +CONNECT_LOCK = threading.Lock() +CONNECT_TIMEOUT = 30 + +DEFAULT_NAME = 'Skybeacon' + +SKIP_HANDLE_LOOKUP = True + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MAC): cv.string, - vol.Optional(CONF_NAME, default=""): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -BLE_TEMP_UUID = '0000ff92-0000-1000-8000-00805f9b34fb' -BLE_TEMP_HANDLE = 0x24 -SKIP_HANDLE_LOOKUP = True -CONNECT_TIMEOUT = 30 - # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Skybeacon sensor.""" - _LOGGER.warning("This platform has been disabled due to having a " - "requirement depending on enum34.") - return # pylint: disable=unreachable name = config.get(CONF_NAME) mac = config.get(CONF_MAC) @@ -150,7 +150,7 @@ class Monitor(threading.Thread): adapter = pygatt.backends.GATTToolBackend() while True: try: - _LOGGER.info("Connecting to %s", self.name) + _LOGGER.debug("Connecting to %s", self.name) # We need concurrent connect, so lets not reset the device adapter.start(reset_on_start=False) # Seems only one connection can be initiated at a time diff --git a/homeassistant/components/sensor/smappee.py b/homeassistant/components/sensor/smappee.py index c59798d16d7..5b84962144d 100644 --- a/homeassistant/components/sensor/smappee.py +++ b/homeassistant/components/sensor/smappee.py @@ -31,7 +31,19 @@ SENSOR_TYPES = { 'solar_today': ['Solar Today', 'mdi:white-balance-sunny', 'remote', 'kWh', 'solar'], 'power_today': - ['Power Today', 'mdi:power-plug', 'remote', 'kWh', 'consumption'] + ['Power Today', 'mdi:power-plug', 'remote', 'kWh', 'consumption'], + 'water_sensor_1': + ['Water Sensor 1', 'mdi:water', 'water', 'm3', 'value1'], + 'water_sensor_2': + ['Water Sensor 2', 'mdi:water', 'water', 'm3', 'value2'], + 'water_sensor_temperature': + ['Water Sensor Temperature', 'mdi:temperature-celsius', + 'water', '°', 'temperature'], + 'water_sensor_humidity': + ['Water Sensor Humidity', 'mdi:water-percent', 'water', + '%', 'humidity'], + 'water_sensor_battery': + ['Water Sensor Battery', 'mdi:battery', 'water', '%', 'battery'], } SCAN_INTERVAL = timedelta(seconds=30) @@ -43,36 +55,50 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = [] if smappee.is_remote_active: - for sensor in SENSOR_TYPES: - if 'remote' in SENSOR_TYPES[sensor]: - for location_id in smappee.locations.keys(): - dev.append(SmappeeSensor(smappee, location_id, sensor)) + for location_id in smappee.locations.keys(): + for sensor in SENSOR_TYPES: + if 'remote' in SENSOR_TYPES[sensor]: + dev.append(SmappeeSensor(smappee, location_id, + sensor, + SENSOR_TYPES[sensor])) + elif 'water' in SENSOR_TYPES[sensor]: + for items in smappee.info[location_id].get('sensors'): + dev.append(SmappeeSensor( + smappee, + location_id, + '{}:{}'.format(sensor, items.get('id')), + SENSOR_TYPES[sensor])) if smappee.is_local_active: - for sensor in SENSOR_TYPES: - if 'local' in SENSOR_TYPES[sensor]: - if smappee.is_remote_active: - for location_id in smappee.locations.keys(): - dev.append(SmappeeSensor(smappee, location_id, sensor)) - else: - dev.append(SmappeeSensor(smappee, None, sensor)) + for location_id in smappee.locations.keys(): + for sensor in SENSOR_TYPES: + if 'local' in SENSOR_TYPES[sensor]: + if smappee.is_remote_active: + dev.append(SmappeeSensor(smappee, location_id, sensor, + SENSOR_TYPES[sensor])) + else: + dev.append(SmappeeSensor(smappee, None, sensor, + SENSOR_TYPES[sensor])) + add_devices(dev, True) class SmappeeSensor(Entity): """Implementation of a Smappee sensor.""" - def __init__(self, smappee, location_id, sensor): - """Initialize the sensor.""" + def __init__(self, smappee, location_id, sensor, attributes): + """Initialize the Smappee sensor.""" self._smappee = smappee self._location_id = location_id + self._attributes = attributes self._sensor = sensor self.data = None self._state = None - self._name = SENSOR_TYPES[self._sensor][0] - self._icon = SENSOR_TYPES[self._sensor][1] - self._unit_of_measurement = SENSOR_TYPES[self._sensor][3] - self._smappe_name = SENSOR_TYPES[self._sensor][4] + self._name = self._attributes[0] + self._icon = self._attributes[1] + self._type = self._attributes[2] + self._unit_of_measurement = self._attributes[3] + self._smappe_name = self._attributes[4] @property def name(self): @@ -82,9 +108,7 @@ class SmappeeSensor(Entity): else: location_name = 'Local' - return "{} {} {}".format(SENSOR_PREFIX, - location_name, - self._name) + return "{} {} {}".format(SENSOR_PREFIX, location_name, self._name) @property def icon(self): @@ -160,3 +184,13 @@ class SmappeeSensor(Entity): if i['key'].endswith('phase5ActivePower')] power = sum(value1 + value2 + value3) / 1000 self._state = round(power, 2) + elif self._type == 'water': + sensor_name, sensor_id = self._sensor.split(":") + data = self._smappee.sensor_consumption[self._location_id]\ + .get(int(sensor_id)) + if data: + consumption = data.get('records')[-1] + _LOGGER.debug("%s (%s) %s", + sensor_name, sensor_id, consumption) + value = consumption.get(self._smappe_name) + self._state = value diff --git a/homeassistant/components/sensor/socialblade.py b/homeassistant/components/sensor/socialblade.py new file mode 100644 index 00000000000..1e0084e1404 --- /dev/null +++ b/homeassistant/components/sensor/socialblade.py @@ -0,0 +1,90 @@ +""" +Support for Social Blade. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.socialblade/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['socialbladeclient==0.2'] + +CHANNEL_ID = 'channel_id' + +DEFAULT_NAME = "Social Blade" + +MIN_TIME_BETWEEN_UPDATES = timedelta(hours=2) + +SUBSCRIBERS = 'subscribers' + +TOTAL_VIEWS = 'total_views' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CHANNEL_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Social Blade sensor.""" + social_blade = SocialBladeSensor( + config[CHANNEL_ID], config[CONF_NAME]) + + social_blade.update() + if social_blade.valid_channel_id is False: + return + + add_devices([social_blade]) + + +class SocialBladeSensor(Entity): + """Representation of a Social Blade Sensor.""" + + def __init__(self, case, name): + """Initialize the Social Blade sensor.""" + self._state = None + self.channel_id = case + self._attributes = None + self.valid_channel_id = None + self._name = name + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._attributes: + return self._attributes + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from Social Blade.""" + import socialbladeclient + try: + data = socialbladeclient.get_data(self.channel_id) + self._attributes = {TOTAL_VIEWS: data[TOTAL_VIEWS]} + self._state = data[SUBSCRIBERS] + self.valid_channel_id = True + + except (ValueError, IndexError): + _LOGGER.error("Unable to find valid channel ID") + self.valid_channel_id = False + self._attributes = None diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index 5b03be036d5..bf2868d3b01 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -17,7 +17,7 @@ from homeassistant.helpers.event import track_time_change from homeassistant.helpers.restore_state import async_get_last_state import homeassistant.util.dt as dt_util -REQUIREMENTS = ['speedtest-cli==2.0.0'] +REQUIREMENTS = ['speedtest-cli==2.0.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/spotcrime.py b/homeassistant/components/sensor/spotcrime.py index 169bcc5f867..08177c9a7b9 100644 --- a/homeassistant/components/sensor/spotcrime.py +++ b/homeassistant/components/sensor/spotcrime.py @@ -12,14 +12,15 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_INCLUDE, CONF_EXCLUDE, CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, - ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_RADIUS) +from homeassistant.const import (CONF_API_KEY, CONF_INCLUDE, CONF_EXCLUDE, + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, + ATTR_ATTRIBUTION, ATTR_LATITUDE, + ATTR_LONGITUDE, CONF_RADIUS) from homeassistant.helpers.entity import Entity from homeassistant.util import slugify import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['spotcrime==1.0.2'] +REQUIREMENTS = ['spotcrime==1.0.3'] _LOGGER = logging.getLogger(__name__) @@ -34,6 +35,7 @@ SCAN_INTERVAL = timedelta(minutes=30) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_RADIUS): vol.Coerce(float), + vol.Required(CONF_API_KEY): cv.string, vol.Inclusive(CONF_LATITUDE, 'coordinates'): cv.latitude, vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude, vol.Optional(CONF_DAYS, default=DEFAULT_DAYS): cv.positive_int, @@ -49,28 +51,31 @@ def setup_platform(hass, config, add_devices, discovery_info=None): longitude = config.get(CONF_LONGITUDE, hass.config.longitude) name = config[CONF_NAME] radius = config[CONF_RADIUS] + api_key = config[CONF_API_KEY] days = config.get(CONF_DAYS) include = config.get(CONF_INCLUDE) exclude = config.get(CONF_EXCLUDE) add_devices([SpotCrimeSensor( name, latitude, longitude, radius, include, - exclude, days)], True) + exclude, api_key, days)], True) class SpotCrimeSensor(Entity): """Representation of a Spot Crime Sensor.""" def __init__(self, name, latitude, longitude, radius, - include, exclude, days): + include, exclude, api_key, days): """Initialize the Spot Crime sensor.""" import spotcrime self._name = name self._include = include self._exclude = exclude + self.api_key = api_key self.days = days self._spotcrime = spotcrime.SpotCrime( - (latitude, longitude), radius, None, None, self.days) + (latitude, longitude), radius, self._include, + self._exclude, self.api_key, self.days) self._attributes = None self._state = None self._previous_incidents = set() diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index 5d5d61ff822..7fefb0f450b 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.sql/ """ import decimal +import datetime import logging import voluptuous as vol @@ -19,11 +20,11 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['sqlalchemy==1.2.2'] +REQUIREMENTS = ['sqlalchemy==1.2.8'] +CONF_COLUMN_NAME = 'column' CONF_QUERIES = 'queries' CONF_QUERY = 'query' -CONF_COLUMN_NAME = 'column' def validate_sql_select(value): @@ -34,9 +35,9 @@ def validate_sql_select(value): _QUERY_SCHEME = vol.Schema({ + vol.Required(CONF_COLUMN_NAME): cv.string, vol.Required(CONF_NAME): cv.string, vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select), - vol.Required(CONF_COLUMN_NAME): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }) @@ -48,7 +49,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the sensor platform.""" + """Set up the SQL sensor platform.""" db_url = config.get(CONF_DB_URL, None) if not db_url: db_url = DEFAULT_URL.format( @@ -90,10 +91,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class SQLSensor(Entity): - """An SQL sensor.""" + """Representation of an SQL sensor.""" def __init__(self, name, sessmaker, query, column, unit, value_template): - """Initialize SQL sensor.""" + """Initialize the SQL sensor.""" self._name = name if "LIMIT" in query: self._query = query @@ -145,6 +146,8 @@ class SQLSensor(Entity): for key, value in res.items(): if isinstance(value, decimal.Decimal): value = float(value) + if isinstance(value, datetime.date): + value = str(value) self._attributes[key] = value except sqlalchemy.exc.SQLAlchemyError as err: _LOGGER.error("Error executing query %s: %s", self._query, err) diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index 7b2ae537d4b..a77509c18d4 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -156,7 +156,7 @@ class StatisticsSensor(Entity): ATTR_CHANGE: self.change, ATTR_AVERAGE_CHANGE: self.average_change, } - # Only return min/max age if we have a age span + # Only return min/max age if we have an age span if self._max_age: state.update({ ATTR_MAX_AGE: self.max_age, diff --git a/homeassistant/components/sensor/swiss_public_transport.py b/homeassistant/components/sensor/swiss_public_transport.py index a489adf6776..928d84caa2b 100644 --- a/homeassistant/components/sensor/swiss_public_transport.py +++ b/homeassistant/components/sensor/swiss_public_transport.py @@ -4,7 +4,6 @@ Support for transport.opendata.ch. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.swiss_public_transport/ """ -import asyncio from datetime import timedelta import logging @@ -17,7 +16,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util -REQUIREMENTS = ['python_opendata_transport==0.0.3'] +REQUIREMENTS = ['python_opendata_transport==0.1.0'] _LOGGER = logging.getLogger(__name__) @@ -48,8 +47,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 Swiss public transport sensor.""" from opendata_transport import OpendataTransport, exceptions @@ -61,7 +60,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): opendata = OpendataTransport(start, destination, hass.loop, session) try: - yield from opendata.async_get_data() + await opendata.async_get_data() except exceptions.OpendataTransportError: _LOGGER.error( "Check at http://transport.opendata.ch/examples/stationboard.html " @@ -122,12 +121,11 @@ class SwissPublicTransportSensor(Entity): """Icon to use in the frontend, if any.""" return ICON - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get the latest data from opendata.ch and update the states.""" from opendata_transport.exceptions import OpendataTransportError try: - yield from self._opendata.async_get_data() + await self._opendata.async_get_data() except OpendataTransportError: _LOGGER.error("Unable to retrieve data from transport.opendata.ch") diff --git a/homeassistant/components/sensor/syncthru.py b/homeassistant/components/sensor/syncthru.py new file mode 100644 index 00000000000..a24482bda01 --- /dev/null +++ b/homeassistant/components/sensor/syncthru.py @@ -0,0 +1,233 @@ +""" +Support for Samsung Printers with SyncThru web interface. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.syncthru/ +""" + +import logging +import voluptuous as vol + +from homeassistant.const import ( + CONF_RESOURCE, CONF_HOST, CONF_NAME, CONF_MONITORED_CONDITIONS) +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA + +REQUIREMENTS = ['pysyncthru==0.3.1'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Samsung Printer' +DEFAULT_MONITORED_CONDITIONS = [ + 'toner_black', + 'toner_cyan', + 'toner_magenta', + 'toner_yellow', + 'drum_black', + 'drum_cyan', + 'drum_magenta', + 'drum_yellow', + 'tray_1', + 'tray_2', + 'tray_3', + 'tray_4', + 'tray_5', + 'output_tray_0', + 'output_tray_1', + 'output_tray_2', + 'output_tray_3', + 'output_tray_4', + 'output_tray_5', +] +COLORS = [ + 'black', + 'cyan', + 'magenta', + 'yellow' +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional( + CONF_NAME, + default=DEFAULT_NAME + ): cv.string, + vol.Optional( + CONF_MONITORED_CONDITIONS, + default=DEFAULT_MONITORED_CONDITIONS + ): vol.All(cv.ensure_list, [vol.In(DEFAULT_MONITORED_CONDITIONS)]) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the SyncThru component.""" + from pysyncthru import SyncThru, test_syncthru + + if discovery_info is not None: + host = discovery_info.get(CONF_HOST) + name = discovery_info.get(CONF_NAME, DEFAULT_NAME) + _LOGGER.debug("Discovered a new Samsung Printer: %s", discovery_info) + # Test if the discovered device actually is a syncthru printer + if not test_syncthru(host): + _LOGGER.error("No SyncThru Printer found at %s", host) + return + monitored = DEFAULT_MONITORED_CONDITIONS + else: + host = config.get(CONF_RESOURCE) + name = config.get(CONF_NAME) + monitored = config.get(CONF_MONITORED_CONDITIONS) + + # Main device, always added + try: + printer = SyncThru(host) + except TypeError: + # if an exception is thrown, printer cannot be set up + return + + printer.update() + devices = [SyncThruMainSensor(printer, name)] + + for key in printer.toner_status(filter_supported=True): + if 'toner_{}'.format(key) in monitored: + devices.append(SyncThruTonerSensor(printer, name, key)) + for key in printer.drum_status(filter_supported=True): + if 'drum_{}'.format(key) in monitored: + devices.append(SyncThruDrumSensor(printer, name, key)) + for key in printer.input_tray_status(filter_supported=True): + if 'tray_{}'.format(key) in monitored: + devices.append(SyncThruInputTraySensor(printer, name, key)) + for key in printer.output_tray_status(): + if 'output_tray_{}'.format(key) in monitored: + devices.append(SyncThruOutputTraySensor(printer, name, key)) + + add_devices(devices, True) + + +class SyncThruSensor(Entity): + """Implementation of an abstract Samsung Printer sensor platform.""" + + def __init__(self, syncthru, name): + """Initialize the sensor.""" + self.syncthru = syncthru + self._attributes = {} + self._state = None + self._name = name + self._icon = 'mdi:printer' + self._unit_of_measurement = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def icon(self): + """Return the icon of the device.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the unit of measuremnt.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._attributes + + +class SyncThruMainSensor(SyncThruSensor): + """Implementation of the main sensor, monitoring the general state.""" + + def update(self): + """Get the latest data from SyncThru and update the state.""" + self.syncthru.update() + self._state = self.syncthru.device_status() + + +class SyncThruTonerSensor(SyncThruSensor): + """Implementation of a Samsung Printer toner sensor platform.""" + + def __init__(self, syncthru, name, color): + """Initialize the sensor.""" + super().__init__(syncthru, name) + self._name = "{} Toner {}".format(name, color) + self._color = color + self._unit_of_measurement = '%' + + def update(self): + """Get the latest data from SyncThru and update the state.""" + # Data fetching is taken care of through the Main sensor + + if self.syncthru.is_online(): + self._attributes = self.syncthru.toner_status( + ).get(self._color, {}) + self._state = self._attributes.get('remaining') + + +class SyncThruDrumSensor(SyncThruSensor): + """Implementation of a Samsung Printer toner sensor platform.""" + + def __init__(self, syncthru, name, color): + """Initialize the sensor.""" + super().__init__(syncthru, name) + self._name = "{} Drum {}".format(name, color) + self._color = color + self._unit_of_measurement = '%' + + def update(self): + """Get the latest data from SyncThru and update the state.""" + # Data fetching is taken care of through the Main sensor + + if self.syncthru.is_online(): + self._attributes = self.syncthru.drum_status( + ).get(self._color, {}) + self._state = self._attributes.get('remaining') + + +class SyncThruInputTraySensor(SyncThruSensor): + """Implementation of a Samsung Printer input tray sensor platform.""" + + def __init__(self, syncthru, name, number): + """Initialize the sensor.""" + super().__init__(syncthru, name) + self._name = "{} Tray {}".format(name, number) + self._number = number + + def update(self): + """Get the latest data from SyncThru and update the state.""" + # Data fetching is taken care of through the Main sensor + + if self.syncthru.is_online(): + self._attributes = self.syncthru.input_tray_status( + ).get(self._number, {}) + self._state = self._attributes.get('newError') + if self._state == '': + self._state = 'Ready' + + +class SyncThruOutputTraySensor(SyncThruSensor): + """Implementation of a Samsung Printer input tray sensor platform.""" + + def __init__(self, syncthru, name, number): + """Initialize the sensor.""" + super().__init__(syncthru, name) + self._name = "{} Output Tray {}".format(name, number) + self._number = number + + def update(self): + """Get the latest data from SyncThru and update the state.""" + # Data fetching is taken care of through the Main sensor + + if self.syncthru.is_online(): + self._attributes = self.syncthru.output_tray_status( + ).get(self._number, {}) + self._state = self._attributes.get('status') + if self._state == '': + self._state = 'Ready' diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 79d5c261b88..0b85de8e4f2 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.4.3'] +REQUIREMENTS = ['psutil==5.4.5'] _LOGGER = logging.getLogger(__name__) @@ -42,8 +42,8 @@ SENSOR_TYPES = { 'process': ['Process', ' ', 'mdi:memory'], 'processor_use': ['Processor use', '%', 'mdi:memory'], 'since_last_boot': ['Since last boot', '', 'mdi:clock'], - 'swap_free': ['Swap free', 'GiB', 'mdi:harddisk'], - 'swap_use': ['Swap use', 'GiB', 'mdi:harddisk'], + 'swap_free': ['Swap free', 'MiB', 'mdi:harddisk'], + 'swap_use': ['Swap use', 'MiB', 'mdi:harddisk'], 'swap_use_percent': ['Swap use (percent)', '%', 'mdi:harddisk'], } @@ -135,9 +135,9 @@ class SystemMonitorSensor(Entity): elif self.type == 'swap_use_percent': self._state = psutil.swap_memory().percent elif self.type == 'swap_use': - self._state = round(psutil.swap_memory().used / 1024**3, 1) + self._state = round(psutil.swap_memory().used / 1024**2, 1) elif self.type == 'swap_free': - self._state = round(psutil.swap_memory().free / 1024**3, 1) + self._state = round(psutil.swap_memory().free / 1024**2, 1) elif self.type == 'processor_use': self._state = round(psutil.cpu_percent(interval=None)) elif self.type == 'process': diff --git a/homeassistant/components/sensor/tado.py b/homeassistant/components/sensor/tado.py index 7acdc1a20bd..ff8ad7fe849 100644 --- a/homeassistant/components/sensor/tado.py +++ b/homeassistant/components/sensor/tado.py @@ -6,16 +6,14 @@ https://home-assistant.io/components/sensor.tado/ """ import logging -from homeassistant.const import TEMP_CELSIUS +from homeassistant.components.tado import DATA_TADO +from homeassistant.const import ATTR_ID, ATTR_NAME, TEMP_CELSIUS from homeassistant.helpers.entity import Entity -from homeassistant.components.tado import (DATA_TADO) -from homeassistant.const import (ATTR_ID) _LOGGER = logging.getLogger(__name__) ATTR_DATA_ID = 'data_id' ATTR_DEVICE = 'device' -ATTR_NAME = 'name' ATTR_ZONE = 'zone' CLIMATE_SENSOR_TYPES = ['temperature', 'humidity', 'power', @@ -39,14 +37,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if zone['type'] == 'HEATING': for variable in CLIMATE_SENSOR_TYPES: sensor_items.append(create_zone_sensor( - tado, zone, zone['name'], zone['id'], - variable)) + tado, zone, zone['name'], zone['id'], variable)) elif zone['type'] == 'HOT_WATER': for variable in HOT_WATER_SENSOR_TYPES: sensor_items.append(create_zone_sensor( - tado, zone, zone['name'], zone['id'], - variable - )) + tado, zone, zone['name'], zone['id'], variable)) me_data = tado.get_me() sensor_items.append(create_device_sensor( diff --git a/homeassistant/components/sensor/tahoma.py b/homeassistant/components/sensor/tahoma.py index 39d1cbc75a3..aedecfe61e5 100644 --- a/homeassistant/components/sensor/tahoma.py +++ b/homeassistant/components/sensor/tahoma.py @@ -46,8 +46,10 @@ class TahomaSensor(TahomaDevice, Entity): """Return the unit of measurement of this entity, if any.""" if self.tahoma_device.type == 'Temperature Sensor': return None + elif self.tahoma_device.type == 'io:SomfyContactIOSystemSensor': + return None elif self.tahoma_device.type == 'io:LightIOSystemSensor': - return 'lux' + return 'lx' elif self.tahoma_device.type == 'Humidity Sensor': return '%' @@ -57,3 +59,6 @@ class TahomaSensor(TahomaDevice, Entity): if self.tahoma_device.type == 'io:LightIOSystemSensor': self.current_value = self.tahoma_device.active_states[ 'core:LuminanceState'] + if self.tahoma_device.type == 'io:SomfyContactIOSystemSensor': + self.current_value = self.tahoma_device.active_states[ + 'core:ContactState'] diff --git a/homeassistant/components/sensor/tellduslive.py b/homeassistant/components/sensor/tellduslive.py index 61a084c6266..048ca988e3d 100644 --- a/homeassistant/components/sensor/tellduslive.py +++ b/homeassistant/components/sensor/tellduslive.py @@ -7,7 +7,9 @@ https://home-assistant.io/components/sensor.tellduslive/ import logging from homeassistant.components.tellduslive import TelldusLiveEntity -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS) _LOGGER = logging.getLogger(__name__) @@ -25,18 +27,20 @@ SENSOR_TYPE_DEW_POINT = 'dewp' SENSOR_TYPE_BAROMETRIC_PRESSURE = 'barpress' SENSOR_TYPES = { - SENSOR_TYPE_TEMPERATURE: ['Temperature', TEMP_CELSIUS, 'mdi:thermometer'], - SENSOR_TYPE_HUMIDITY: ['Humidity', '%', 'mdi:water'], - SENSOR_TYPE_RAINRATE: ['Rain rate', 'mm/h', 'mdi:water'], - SENSOR_TYPE_RAINTOTAL: ['Rain total', 'mm', 'mdi:water'], - SENSOR_TYPE_WINDDIRECTION: ['Wind direction', '', ''], - SENSOR_TYPE_WINDAVERAGE: ['Wind average', 'm/s', ''], - SENSOR_TYPE_WINDGUST: ['Wind gust', 'm/s', ''], - SENSOR_TYPE_UV: ['UV', 'UV', ''], - SENSOR_TYPE_WATT: ['Power', 'W', ''], - SENSOR_TYPE_LUMINANCE: ['Luminance', 'lx', ''], - SENSOR_TYPE_DEW_POINT: ['Dew Point', TEMP_CELSIUS, 'mdi:thermometer'], - SENSOR_TYPE_BAROMETRIC_PRESSURE: ['Barometric Pressure', 'kPa', ''], + SENSOR_TYPE_TEMPERATURE: ['Temperature', TEMP_CELSIUS, None, + DEVICE_CLASS_TEMPERATURE], + SENSOR_TYPE_HUMIDITY: ['Humidity', '%', None, DEVICE_CLASS_HUMIDITY], + SENSOR_TYPE_RAINRATE: ['Rain rate', 'mm/h', 'mdi:water', None], + SENSOR_TYPE_RAINTOTAL: ['Rain total', 'mm', 'mdi:water', None], + SENSOR_TYPE_WINDDIRECTION: ['Wind direction', '', '', None], + SENSOR_TYPE_WINDAVERAGE: ['Wind average', 'm/s', '', None], + SENSOR_TYPE_WINDGUST: ['Wind gust', 'm/s', '', None], + SENSOR_TYPE_UV: ['UV', 'UV', '', None], + SENSOR_TYPE_WATT: ['Power', 'W', '', None], + SENSOR_TYPE_LUMINANCE: ['Luminance', 'lx', None, DEVICE_CLASS_ILLUMINANCE], + SENSOR_TYPE_DEW_POINT: + ['Dew Point', TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], + SENSOR_TYPE_BAROMETRIC_PRESSURE: ['Barometric Pressure', 'kPa', '', None], } @@ -117,3 +121,9 @@ class TelldusLiveSensor(TelldusLiveEntity): """Return the icon.""" return SENSOR_TYPES[self._type][2] \ if self._type in SENSOR_TYPES else None + + @property + def device_class(self): + """Return the device class.""" + return SENSOR_TYPES[self._type][3] \ + if self._type in SENSOR_TYPES else None diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index 1cd43262513..65f49998dbf 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -6,16 +6,18 @@ https://home-assistant.io/components/sensor.template/ """ import asyncio import logging +from typing import Optional import voluptuous as vol from homeassistant.core import callback -from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA +from homeassistant.components.sensor import ENTITY_ID_FORMAT, \ + PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, CONF_ENTITY_PICTURE_TEMPLATE, ATTR_ENTITY_ID, CONF_SENSORS, EVENT_HOMEASSISTANT_START, CONF_FRIENDLY_NAME_TEMPLATE, - MATCH_ALL) + MATCH_ALL, CONF_DEVICE_CLASS) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity, async_generate_entity_id @@ -30,6 +32,7 @@ SENSOR_SCHEMA = vol.Schema({ vol.Optional(CONF_FRIENDLY_NAME_TEMPLATE): cv.template, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids }) @@ -52,6 +55,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) friendly_name_template = device_config.get(CONF_FRIENDLY_NAME_TEMPLATE) unit_of_measurement = device_config.get(ATTR_UNIT_OF_MEASUREMENT) + device_class = device_config.get(CONF_DEVICE_CLASS) entity_ids = set() manual_entity_ids = device_config.get(ATTR_ENTITY_ID) @@ -86,7 +90,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): state_template, icon_template, entity_picture_template, - entity_ids) + entity_ids, + device_class) ) if not sensors: _LOGGER.error("No sensors added") @@ -101,7 +106,7 @@ class SensorTemplate(Entity): def __init__(self, hass, device_id, friendly_name, friendly_name_template, unit_of_measurement, state_template, icon_template, - entity_picture_template, entity_ids): + entity_picture_template, entity_ids, device_class): """Initialize the sensor.""" self.hass = hass self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, @@ -116,6 +121,7 @@ class SensorTemplate(Entity): self._icon = None self._entity_picture = None self._entities = entity_ids + self._device_class = device_class @asyncio.coroutine def async_added_to_hass(self): @@ -151,6 +157,11 @@ class SensorTemplate(Entity): """Return the icon to use in the frontend, if any.""" return self._icon + @property + def device_class(self) -> Optional[str]: + """Return the device class of the sensor.""" + return self._device_class + @property def entity_picture(self): """Return the entity_picture to use in the frontend, if any.""" diff --git a/homeassistant/components/sensor/tesla.py b/homeassistant/components/sensor/tesla.py index 1ffc97bb137..3233ebb1780 100644 --- a/homeassistant/components/sensor/tesla.py +++ b/homeassistant/components/sensor/tesla.py @@ -86,6 +86,8 @@ class TeslaSensor(TeslaDevice, Entity): self._unit = LENGTH_MILES else: self._unit = LENGTH_KILOMETERS + self.current_value /= 0.621371 + self.current_value = round(self.current_value, 2) else: self.current_value = self.tesla_device.get_value() if self.tesla_device.bin_type == 0x5: @@ -95,3 +97,5 @@ class TeslaSensor(TeslaDevice, Entity): self._unit = LENGTH_MILES else: self._unit = LENGTH_KILOMETERS + self.current_value /= 0.621371 + self.current_value = round(self.current_value, 2) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 8c8ffdfd954..42568a6b9ad 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -19,8 +19,9 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util +from homeassistant.util import Throttle -REQUIREMENTS = ['pyTibber==0.4.0'] +REQUIREMENTS = ['pyTibber==0.4.1'] _LOGGER = logging.getLogger(__name__) @@ -30,6 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ ICON = 'mdi:currency-usd' SCAN_INTERVAL = timedelta(minutes=1) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) async def async_setup_platform(hass, config, async_add_devices, @@ -58,48 +60,39 @@ class TibberSensor(Entity): """Initialize the sensor.""" self._tibber_home = tibber_home self._last_updated = None + self._last_data_timestamp = None self._state = None + self._is_available = False self._device_state_attributes = {} self._unit_of_measurement = self._tibber_home.price_unit - self._name = 'Electricity price {}'.format(tibber_home.address1) + self._name = 'Electricity price {}'.format(tibber_home.info['viewer'] + ['home']['appNickname']) async def async_update(self): """Get the latest data and updates the states.""" - now = dt_util.utcnow() + now = dt_util.now() if self._tibber_home.current_price_total and self._last_updated and \ - dt_util.as_utc(dt_util.parse_datetime(self._last_updated)).hour\ - == now.hour: + self._last_updated.hour == now.hour and self._last_data_timestamp: return - def _find_current_price(): - for key, price_total in self._tibber_home.price_total.items(): - price_time = dt_util.as_utc(dt_util.parse_datetime(key)) - time_diff = (now - price_time).total_seconds()/60 - if time_diff >= 0 and time_diff < 60: - self._state = round(price_total, 3) - self._last_updated = key - return True - return False + if (not self._last_data_timestamp or + (self._last_data_timestamp - now).total_seconds()/3600 < 12 + or not self._is_available): + _LOGGER.debug("Asking for new data.") + await self._fetch_data() - if _find_current_price(): - return - - _LOGGER.debug("No cached data found, so asking for new data") - await self._tibber_home.update_info() - await self._tibber_home.update_price_info() - data = self._tibber_home.info['viewer']['home'] - self._device_state_attributes['app_nickname'] = data['appNickname'] - self._device_state_attributes['grid_company'] =\ - data['meteringPointData']['gridCompany'] - self._device_state_attributes['estimated_annual_consumption'] =\ - data['meteringPointData']['estimatedAnnualConsumption'] - _find_current_price() + self._is_available = self._update_current_price() @property def device_state_attributes(self): """Return the state attributes.""" return self._device_state_attributes + @property + def available(self): + """Return True if entity is available.""" + return self._is_available + @property def name(self): """Return the name of the sensor.""" @@ -125,3 +118,40 @@ class TibberSensor(Entity): """Return a unique ID.""" home = self._tibber_home.info['viewer']['home'] return home['meteringPointData']['consumptionEan'] + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def _fetch_data(self): + try: + await self._tibber_home.update_info() + await self._tibber_home.update_price_info() + except (asyncio.TimeoutError, aiohttp.ClientError): + return + data = self._tibber_home.info['viewer']['home'] + self._device_state_attributes['app_nickname'] = data['appNickname'] + self._device_state_attributes['grid_company'] = \ + data['meteringPointData']['gridCompany'] + self._device_state_attributes['estimated_annual_consumption'] = \ + data['meteringPointData']['estimatedAnnualConsumption'] + + def _update_current_price(self): + state = None + max_price = 0 + min_price = 10000 + now = dt_util.now() + for key, price_total in self._tibber_home.price_total.items(): + price_time = dt_util.as_local(dt_util.parse_datetime(key)) + price_total = round(price_total, 3) + time_diff = (now - price_time).total_seconds()/60 + if (not self._last_data_timestamp or + price_time > self._last_data_timestamp): + self._last_data_timestamp = price_time + if 0 <= time_diff < 60: + state = price_total + self._last_updated = price_time + if now.date() == price_time.date(): + max_price = max(max_price, price_total) + min_price = min(min_price, price_total) + self._state = state + self._device_state_attributes['max_price'] = max_price + self._device_state_attributes['min_price'] = min_price + return state is not None diff --git a/homeassistant/components/sensor/tradfri.py b/homeassistant/components/sensor/tradfri.py index d087fdda9f6..df931770cf2 100644 --- a/homeassistant/components/sensor/tradfri.py +++ b/homeassistant/components/sensor/tradfri.py @@ -4,7 +4,6 @@ Support for the IKEA Tradfri platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.tradfri/ """ -import asyncio import logging from datetime import timedelta @@ -20,8 +19,8 @@ DEPENDENCIES = ['tradfri'] SCAN_INTERVAL = timedelta(minutes=5) -@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 IKEA Tradfri device platform.""" if discovery_info is None: return @@ -31,8 +30,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): gateway = hass.data[KEY_GATEWAY][gateway_id] devices_command = gateway.get_devices() - devices_commands = yield from api(devices_command) - all_devices = yield from api(devices_commands) + devices_commands = await api(devices_command) + all_devices = await api(devices_commands) devices = [dev for dev in all_devices if not dev.has_light_control] async_add_devices(TradfriDevice(device, api) for device in devices) @@ -48,8 +47,7 @@ class TradfriDevice(Entity): self._refresh(device) - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Start thread when added to hass.""" self._async_start_observe() @@ -91,7 +89,7 @@ class TradfriDevice(Entity): def _async_start_observe(self, exc=None): """Start observation of light.""" # pylint: disable=import-error - from pytradfri.error import PyTradFriError + from pytradfri.error import PytradfriError if exc: _LOGGER.warning("Observation failed for %s", self._name, exc_info=exc) @@ -101,7 +99,7 @@ class TradfriDevice(Entity): err_callback=self._async_start_observe, duration=0) self.hass.async_add_job(self._api(cmd)) - except PyTradFriError as err: + except PytradfriError as err: _LOGGER.warning("Observation failed, trying again", exc_info=err) self._async_start_observe() diff --git a/homeassistant/components/sensor/trafikverket_weatherstation.py b/homeassistant/components/sensor/trafikverket_weatherstation.py new file mode 100644 index 00000000000..77a2b0e7338 --- /dev/null +++ b/homeassistant/components/sensor/trafikverket_weatherstation.py @@ -0,0 +1,122 @@ +""" +Weather information for air and road temperature, provided by Trafikverket. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.trafikverket_weatherstation/ +""" +from datetime import timedelta +import json +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME, CONF_TYPE, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Data provided by Trafikverket API" + +CONF_STATION = 'station' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +SCAN_INTERVAL = timedelta(seconds=300) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_STATION): cv.string, + vol.Required(CONF_TYPE): vol.In(['air', 'road']), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Trafikverket sensor platform.""" + sensor_name = config.get(CONF_NAME) + sensor_api = config.get(CONF_API_KEY) + sensor_station = config.get(CONF_STATION) + sensor_type = config.get(CONF_TYPE) + + add_devices([TrafikverketWeatherStation( + sensor_name, sensor_api, sensor_station, sensor_type)], True) + + +class TrafikverketWeatherStation(Entity): + """Representation of a Trafikverket sensor.""" + + def __init__(self, sensor_name, sensor_api, sensor_station, sensor_type): + """Initialize the Trafikverket sensor.""" + self._name = sensor_name + self._api = sensor_api + self._station = sensor_station + self._type = sensor_type + self._state = None + self._attributes = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + } + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Fetch new state data for the sensor.""" + url = 'http://api.trafikinfo.trafikverket.se/v1.3/data.json' + + if self._type == 'road': + air_vs_road = 'Road' + else: + air_vs_road = 'Air' + + xml = """ + + + + + + + Measurement.""" + air_vs_road + """.Temp + + """ + + # Testing JSON post request. + try: + post = requests.post(url, data=xml.encode('utf-8'), timeout=5) + except requests.exceptions.RequestException as err: + _LOGGER.error("Please check network connection: %s", err) + return + + # Checking JSON respons. + try: + data = json.loads(post.text) + result = data["RESPONSE"]["RESULT"][0] + final = result["WeatherStation"][0]["Measurement"] + except KeyError: + _LOGGER.error("Incorrect weather station or API key") + return + + # air_vs_road contains "Air" or "Road" depending on user input. + self._state = final[air_vs_road]["Temp"] diff --git a/homeassistant/components/sensor/transmission.py b/homeassistant/components/sensor/transmission.py index 678d9afb81d..4dac411d224 100644 --- a/homeassistant/components/sensor/transmission.py +++ b/homeassistant/components/sensor/transmission.py @@ -4,23 +4,23 @@ Support for monitoring the Transmission BitTorrent client API. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.transmission/ """ -import logging from datetime import timedelta +import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT, - CONF_MONITORED_VARIABLES, STATE_IDLE) + CONF_HOST, CONF_MONITORED_VARIABLES, CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_USERNAME, STATE_IDLE) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +from homeassistant.exceptions import PlatformNotReady REQUIREMENTS = ['transmissionrpc==0.11'] _LOGGER = logging.getLogger(__name__) -_THROTTLED_REFRESH = None DEFAULT_NAME = 'Transmission' DEFAULT_PORT = 9091 @@ -29,12 +29,16 @@ SENSOR_TYPES = { 'active_torrents': ['Active Torrents', None], 'current_status': ['Status', None], 'download_speed': ['Down Speed', 'MB/s'], + 'paused_torrents': ['Paused Torrents', None], + 'total_torrents': ['Total Torrents', None], 'upload_speed': ['Up Speed', 'MB/s'], } +SCAN_INTERVAL = timedelta(minutes=2) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_MONITORED_VARIABLES, default=[]): + vol.Optional(CONF_MONITORED_VARIABLES, default=['torrents']): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, @@ -43,7 +47,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Transmission sensors.""" import transmissionrpc @@ -56,39 +59,37 @@ def setup_platform(hass, config, add_devices, discovery_info=None): port = config.get(CONF_PORT) try: - transmission_api = transmissionrpc.Client( + transmission = transmissionrpc.Client( host, port=port, user=username, password=password) - transmission_api.session_stats() + transmission_api = TransmissionData(transmission) except TransmissionError as error: - _LOGGER.error( - "Connection to Transmission API failed on %s:%s with message %s", - host, port, error.original - ) - return False + if str(error).find("401: Unauthorized"): + _LOGGER.error("Credentials for Transmission client are not valid") + return - # pylint: disable=global-statement - global _THROTTLED_REFRESH - _THROTTLED_REFRESH = Throttle(timedelta(seconds=1))( - transmission_api.session_stats) + _LOGGER.warning( + "Unable to connect to Transmission client: %s:%s", host, port) + raise PlatformNotReady dev = [] for variable in config[CONF_MONITORED_VARIABLES]: dev.append(TransmissionSensor(variable, transmission_api, name)) - add_devices(dev) + add_devices(dev, True) class TransmissionSensor(Entity): """Representation of a Transmission sensor.""" - def __init__(self, sensor_type, transmission_client, client_name): + def __init__(self, sensor_type, transmission_api, client_name): """Initialize the sensor.""" self._name = SENSOR_TYPES[sensor_type][0] - self.tm_client = transmission_client - self.type = sensor_type - self.client_name = client_name self._state = None + self._transmission_api = transmission_api self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._data = None + self.client_name = client_name + self.type = sensor_type @property def name(self): @@ -105,25 +106,20 @@ class TransmissionSensor(Entity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - # pylint: disable=no-self-use - def refresh_transmission_data(self): - """Call the throttled Transmission refresh method.""" - from transmissionrpc.error import TransmissionError - - if _THROTTLED_REFRESH is not None: - try: - _THROTTLED_REFRESH() - except TransmissionError: - _LOGGER.error("Connection to Transmission API failed") + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._transmission_api.available def update(self): """Get the latest data from Transmission and updates the state.""" - self.refresh_transmission_data() + self._transmission_api.update() + self._data = self._transmission_api.data if self.type == 'current_status': - if self.tm_client.session: - upload = self.tm_client.session.uploadSpeed - download = self.tm_client.session.downloadSpeed + if self._data: + upload = self._data.uploadSpeed + download = self._data.downloadSpeed if upload > 0 and download > 0: self._state = 'Up/Down' elif upload > 0 and download == 0: @@ -135,14 +131,40 @@ class TransmissionSensor(Entity): else: self._state = None - if self.tm_client.session: + if self._data: if self.type == 'download_speed': - mb_spd = float(self.tm_client.session.downloadSpeed) + mb_spd = float(self._data.downloadSpeed) mb_spd = mb_spd / 1024 / 1024 self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1) elif self.type == 'upload_speed': - mb_spd = float(self.tm_client.session.uploadSpeed) + mb_spd = float(self._data.uploadSpeed) mb_spd = mb_spd / 1024 / 1024 self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1) elif self.type == 'active_torrents': - self._state = self.tm_client.session.activeTorrentCount + self._state = self._data.activeTorrentCount + elif self.type == 'paused_torrents': + self._state = self._data.pausedTorrentCount + elif self.type == 'total_torrents': + self._state = self._data.torrentCount + + +class TransmissionData(object): + """Get the latest data and update the states.""" + + def __init__(self, api): + """Initialize the Transmission data object.""" + self.data = None + self.available = True + self._api = api + + @Throttle(SCAN_INTERVAL) + def update(self): + """Get the latest data from Transmission instance.""" + from transmissionrpc.error import TransmissionError + + try: + self.data = self._api.session_stats() + self.available = True + except TransmissionError: + self.available = False + _LOGGER.error("Unable to connect to Transmission client") diff --git a/homeassistant/components/sensor/upnp.py b/homeassistant/components/sensor/upnp.py index e5acae67916..07b63553fcb 100644 --- a/homeassistant/components/sensor/upnp.py +++ b/homeassistant/components/sensor/upnp.py @@ -6,38 +6,50 @@ https://home-assistant.io/components/sensor.upnp/ """ import logging -from homeassistant.components.upnp import DATA_UPNP, UNITS +from homeassistant.components.upnp import DATA_UPNP, UNITS, CIC_SERVICE from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['upnp'] + +BYTES_RECEIVED = 1 +BYTES_SENT = 2 +PACKETS_RECEIVED = 3 +PACKETS_SENT = 4 + # sensor_type: [friendly_name, convert_unit, icon] SENSOR_TYPES = { - 'byte_received': ['received bytes', True, 'mdi:server-network'], - 'byte_sent': ['sent bytes', True, 'mdi:server-network'], - 'packets_in': ['packets received', False, 'mdi:server-network'], - 'packets_out': ['packets sent', False, 'mdi:server-network'], + BYTES_RECEIVED: ['received bytes', True, 'mdi:server-network'], + BYTES_SENT: ['sent bytes', True, 'mdi:server-network'], + PACKETS_RECEIVED: ['packets received', False, 'mdi:server-network'], + PACKETS_SENT: ['packets sent', False, 'mdi:server-network'], } -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the IGD sensors.""" - upnp = hass.data[DATA_UPNP] + if discovery_info is None: + return + + device = hass.data[DATA_UPNP] + service = device.find_first_service(CIC_SERVICE) unit = discovery_info['unit'] - add_devices([ - IGDSensor(upnp, t, unit if SENSOR_TYPES[t][1] else None) + async_add_devices([ + IGDSensor(service, t, unit if SENSOR_TYPES[t][1] else '#') for t in SENSOR_TYPES], True) class IGDSensor(Entity): """Representation of a UPnP IGD sensor.""" - def __init__(self, upnp, sensor_type, unit=""): + def __init__(self, service, sensor_type, unit=None): """Initialize the IGD sensor.""" - self._upnp = upnp + self._service = service self.type = sensor_type self.unit = unit - self.unit_factor = UNITS[unit] if unit is not None else 1 + self.unit_factor = UNITS[unit] if unit in UNITS else 1 self._name = 'IGD {}'.format(SENSOR_TYPES[sensor_type][0]) self._state = None @@ -49,9 +61,9 @@ class IGDSensor(Entity): @property def state(self): """Return the state of the device.""" - if self._state is None: - return None - return format(self._state / self.unit_factor, '.1f') + if self._state: + return format(float(self._state) / self.unit_factor, '.1f') + return self._state @property def icon(self): @@ -63,13 +75,13 @@ class IGDSensor(Entity): """Return the unit of measurement of this entity, if any.""" return self.unit - def update(self): + async def async_update(self): """Get the latest information from the IGD.""" - if self.type == "byte_received": - self._state = self._upnp.totalbytereceived() - elif self.type == "byte_sent": - self._state = self._upnp.totalbytesent() - elif self.type == "packets_in": - self._state = self._upnp.totalpacketreceived() - elif self.type == "packets_out": - self._state = self._upnp.totalpacketsent() + if self.type == BYTES_RECEIVED: + self._state = await self._service.get_total_bytes_received() + elif self.type == BYTES_SENT: + self._state = await self._service.get_total_bytes_sent() + elif self.type == PACKETS_RECEIVED: + self._state = await self._service.get_total_packets_received() + elif self.type == PACKETS_SENT: + self._state = await self._service.get_total_packets_sent() diff --git a/homeassistant/components/sensor/uptime.py b/homeassistant/components/sensor/uptime.py index 91746af71f1..7e893899815 100644 --- a/homeassistant/components/sensor/uptime.py +++ b/homeassistant/components/sensor/uptime.py @@ -1,25 +1,25 @@ """ -Component to retrieve uptime for Home Assistant. +Platform to retrieve uptime for Home Assistant. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.uptime/ """ -import asyncio import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_NAME, CONF_UNIT_OF_MEASUREMENT) +from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Uptime' +ICON = 'mdi:clock' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='days'): @@ -27,22 +27,22 @@ 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 uptime sensor platform.""" name = config.get(CONF_NAME) units = config.get(CONF_UNIT_OF_MEASUREMENT) + async_add_devices([UptimeSensor(name, units)], True) class UptimeSensor(Entity): """Representation of an uptime sensor.""" - def __init__(self, name, units): + def __init__(self, name, unit): """Initialize the uptime sensor.""" self._name = name - self._icon = 'mdi:clock' - self._units = units + self._unit = unit self.initial = dt_util.now() self._state = None @@ -54,27 +54,28 @@ class UptimeSensor(Entity): @property def icon(self): """Icon to display in the front end.""" - return self._icon + return ICON @property def unit_of_measurement(self): """Return the unit of measurement the value is expressed in.""" - return self._units + return self._unit @property def state(self): """Return the state of the sensor.""" return self._state - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update the state of the sensor.""" delta = dt_util.now() - self.initial div_factor = 3600 + if self.unit_of_measurement == 'days': div_factor *= 24 elif self.unit_of_measurement == 'minutes': div_factor /= 60 + delta = delta.total_seconds() / div_factor self._state = round(delta, 2) _LOGGER.debug("New value: %s", delta) diff --git a/homeassistant/components/sensor/uscis.py b/homeassistant/components/sensor/uscis.py new file mode 100644 index 00000000000..ed3c9ca8587 --- /dev/null +++ b/homeassistant/components/sensor/uscis.py @@ -0,0 +1,87 @@ +""" +Support for USCIS Case Status. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.uscis/ +""" + +import logging +from datetime import timedelta +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers import config_validation as cv +from homeassistant.const import CONF_FRIENDLY_NAME + + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['uscisstatus==0.1.1'] + +DEFAULT_NAME = "USCIS" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FRIENDLY_NAME, default=DEFAULT_NAME): cv.string, + vol.Required('case_id'): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setting the platform in HASS and Case Information.""" + uscis = UscisSensor(config['case_id'], config[CONF_FRIENDLY_NAME]) + uscis.update() + if uscis.valid_case_id: + add_devices([uscis]) + else: + _LOGGER.error("Setup USCIS Sensor Fail" + " check if your Case ID is Valid") + + +class UscisSensor(Entity): + """USCIS Sensor will check case status on daily basis.""" + + MIN_TIME_BETWEEN_UPDATES = timedelta(hours=24) + + CURRENT_STATUS = "current_status" + LAST_CASE_UPDATE = "last_update_date" + + def __init__(self, case, name): + """Initialize the sensor.""" + self._state = None + self._case_id = case + self._attributes = None + self.valid_case_id = None + self._name = name + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Using Request to access USCIS website and fetch data.""" + import uscisstatus + try: + status = uscisstatus.get_case_status(self._case_id) + self._attributes = { + self.CURRENT_STATUS: status['status'] + } + self._state = status['date'] + self.valid_case_id = True + + except ValueError: + _LOGGER("Please Check that you have valid USCIS case id") + self.valid_case_id = False diff --git a/homeassistant/components/sensor/vasttrafik.py b/homeassistant/components/sensor/vasttrafik.py index 983c589c98b..8cd084e1b71 100644 --- a/homeassistant/components/sensor/vasttrafik.py +++ b/homeassistant/components/sensor/vasttrafik.py @@ -30,6 +30,7 @@ CONF_DELAY = 'delay' CONF_DEPARTURES = 'departures' CONF_FROM = 'from' CONF_HEADING = 'heading' +CONF_LINES = 'lines' CONF_KEY = 'key' CONF_SECRET = 'secret' @@ -46,6 +47,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_FROM): cv.string, vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.positive_int, vol.Optional(CONF_HEADING): cv.string, + vol.Optional(CONF_LINES, default=[]): + vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_NAME): cv.string}] }) @@ -61,14 +64,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): VasttrafikDepartureSensor( vasttrafik, planner, departure.get(CONF_NAME), departure.get(CONF_FROM), departure.get(CONF_HEADING), - departure.get(CONF_DELAY))) + departure.get(CONF_LINES), departure.get(CONF_DELAY))) add_devices(sensors, True) class VasttrafikDepartureSensor(Entity): """Implementation of a Vasttrafik Departure Sensor.""" - def __init__(self, vasttrafik, planner, name, departure, heading, delay): + def __init__(self, vasttrafik, planner, name, departure, heading, + lines, delay): """Initialize the sensor.""" self._vasttrafik = vasttrafik self._planner = planner @@ -76,6 +80,7 @@ class VasttrafikDepartureSensor(Entity): self._departure = planner.location_name(departure)[0] self._heading = (planner.location_name(heading)[0] if heading else None) + self._lines = lines if lines else None self._delay = timedelta(minutes=delay) self._departureboard = None @@ -94,15 +99,18 @@ class VasttrafikDepartureSensor(Entity): """Return the state attributes.""" if not self._departureboard: return - departure = self._departureboard[0] - params = { - ATTR_ACCESSIBILITY: departure.get('accessibility', None), - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_DIRECTION: departure.get('direction', None), - ATTR_LINE: departure.get('sname', None), - ATTR_TRACK: departure.get('track', None), - } - return {k: v for k, v in params.items() if v} + + for departure in self._departureboard: + line = departure.get('sname') + if not self._lines or line in self._lines: + params = { + ATTR_ACCESSIBILITY: departure.get('accessibility'), + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_DIRECTION: departure.get('direction'), + ATTR_LINE: departure.get('sname'), + ATTR_TRACK: departure.get('track'), + } + return {k: v for k, v in params.items() if v} @property def state(self): @@ -113,9 +121,18 @@ class VasttrafikDepartureSensor(Entity): self._departure['name'], self._heading['name'] if self._heading else 'ANY') return - if 'rtTime' in self._departureboard[0]: - return self._departureboard[0]['rtTime'] - return self._departureboard[0]['time'] + for departure in self._departureboard: + line = departure.get('sname') + if not self._lines or line in self._lines: + if 'rtTime' in self._departureboard[0]: + return self._departureboard[0]['rtTime'] + return self._departureboard[0]['time'] + # No departures of given lines found + _LOGGER.debug( + "No departures from %s heading %s on line(s) %s", + self._departure['name'], + self._heading['name'] if self._heading else 'ANY', + ', '.join((str(line) for line in self._lines))) @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): diff --git a/homeassistant/components/sensor/vera.py b/homeassistant/components/sensor/vera.py index c81c208e33e..eb8ccae768e 100644 --- a/homeassistant/components/sensor/vera.py +++ b/homeassistant/components/sensor/vera.py @@ -52,7 +52,7 @@ class VeraSensor(VeraDevice, Entity): if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: return self._temperature_units elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: - return 'lux' + return 'lx' elif self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: return 'level' elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: diff --git a/homeassistant/components/sensor/version.py b/homeassistant/components/sensor/version.py index c19d2743563..db61d059783 100644 --- a/homeassistant/components/sensor/version.py +++ b/homeassistant/components/sensor/version.py @@ -4,7 +4,6 @@ Support for displaying the current version of Home Assistant. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.version/ """ -import asyncio import logging import voluptuous as vol @@ -23,8 +22,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 Version sensor platform.""" name = config.get(CONF_NAME) diff --git a/homeassistant/components/sensor/waze_travel_time.py b/homeassistant/components/sensor/waze_travel_time.py new file mode 100644 index 00000000000..dbcfcb9cc27 --- /dev/null +++ b/homeassistant/components/sensor/waze_travel_time.py @@ -0,0 +1,151 @@ +""" +Support for Waze travel time sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.waze_travel_time/ +""" +from datetime import timedelta +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_REGION +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['WazeRouteCalculator==0.5'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_DISTANCE = 'distance' +ATTR_ROUTE = 'route' + +CONF_ATTRIBUTION = "Data provided by the Waze.com" +CONF_DESTINATION = 'destination' +CONF_ORIGIN = 'origin' +CONF_INCL_FILTER = 'incl_filter' +CONF_EXCL_FILTER = 'excl_filter' + +DEFAULT_NAME = 'Waze Travel Time' + +ICON = 'mdi:car' + +REGIONS = ['US', 'NA', 'EU', 'IL'] + +SCAN_INTERVAL = timedelta(minutes=5) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ORIGIN): cv.string, + vol.Required(CONF_DESTINATION): cv.string, + vol.Required(CONF_REGION): vol.In(REGIONS), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_INCL_FILTER): cv.string, + vol.Optional(CONF_EXCL_FILTER): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Waze travel time sensor platform.""" + destination = config.get(CONF_DESTINATION) + name = config.get(CONF_NAME) + origin = config.get(CONF_ORIGIN) + region = config.get(CONF_REGION) + incl_filter = config.get(CONF_INCL_FILTER) + excl_filter = config.get(CONF_EXCL_FILTER) + + try: + waze_data = WazeRouteData( + origin, destination, region, incl_filter, excl_filter) + except requests.exceptions.HTTPError as error: + _LOGGER.error("%s", error) + return + + add_devices([WazeTravelTime(waze_data, name)], True) + + +class WazeTravelTime(Entity): + """Representation of a Waze travel time sensor.""" + + def __init__(self, waze_data, name): + """Initialize the Waze travel time sensor.""" + self._name = name + self._state = None + self.waze_data = waze_data + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return round(self._state['duration']) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return 'min' + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def device_state_attributes(self): + """Return the state attributes of the last update.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_DISTANCE: round(self._state['distance']), + ATTR_ROUTE: self._state['route'], + } + + def update(self): + """Fetch new state data for the sensor.""" + try: + self.waze_data.update() + self._state = self.waze_data.data + except KeyError: + _LOGGER.error("Error retrieving data from server") + + +class WazeRouteData(object): + """Get data from Waze.""" + + def __init__(self, origin, destination, region, incl_filter, excl_filter): + """Initialize the data object.""" + self._destination = destination + self._origin = origin + self._region = region + self._incl_filter = incl_filter + self._excl_filter = excl_filter + self.data = {} + + @Throttle(SCAN_INTERVAL) + def update(self): + """Fetch latest data from Waze.""" + import WazeRouteCalculator + _LOGGER.debug("Update in progress...") + try: + params = WazeRouteCalculator.WazeRouteCalculator( + self._origin, self._destination, self._region, None) + results = params.calc_all_routes_info() + if self._incl_filter is not None: + results = {k: v for k, v in results.items() if + self._incl_filter.lower() in k.lower()} + if self._excl_filter is not None: + results = {k: v for k, v in results.items() if + self._excl_filter.lower() not in k.lower()} + best_route = next(iter(results)) + (duration, distance) = results[best_route] + best_route_str = bytes(best_route, 'ISO-8859-1').decode('UTF-8') + self.data['duration'] = duration + self.data['distance'] = distance + self.data['route'] = best_route_str + except WazeRouteCalculator.WRCError as exp: + _LOGGER.error("Error on retrieving data: %s", exp) + return diff --git a/homeassistant/components/sensor/worldclock.py b/homeassistant/components/sensor/worldclock.py index 839b5776b3c..1240480d4a3 100644 --- a/homeassistant/components/sensor/worldclock.py +++ b/homeassistant/components/sensor/worldclock.py @@ -4,7 +4,6 @@ Support for showing the time in a different time zone. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.worldclock/ """ -import asyncio import logging import voluptuous as vol @@ -29,8 +28,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 World clock sensor.""" name = config.get(CONF_NAME) time_zone = dt_util.get_time_zone(config.get(CONF_TIME_ZONE)) @@ -62,8 +61,7 @@ class WorldClockSensor(Entity): """Icon to use in the frontend, if any.""" return ICON - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get the time and updates the states.""" self._state = dt_util.now(time_zone=self._time_zone).strftime( TIME_STR_FORMAT) diff --git a/homeassistant/components/sensor/wsdot.py b/homeassistant/components/sensor/wsdot.py index fecff260716..0cd5ba44349 100644 --- a/homeassistant/components/sensor/wsdot.py +++ b/homeassistant/components/sensor/wsdot.py @@ -13,24 +13,27 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_API_KEY, CONF_NAME, ATTR_ATTRIBUTION, CONF_ID - ) + CONF_API_KEY, CONF_NAME, ATTR_ATTRIBUTION, CONF_ID, ATTR_NAME) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +ATTR_ACCESS_CODE = 'AccessCode' +ATTR_AVG_TIME = 'AverageTime' +ATTR_CURRENT_TIME = 'CurrentTime' +ATTR_DESCRIPTION = 'Description' +ATTR_TIME_UPDATED = 'TimeUpdated' +ATTR_TRAVEL_TIME_ID = 'TravelTimeID' + +CONF_ATTRIBUTION = "Data provided by WSDOT" + CONF_TRAVEL_TIMES = 'travel_time' -# API codes for travel time details -ATTR_ACCESS_CODE = 'AccessCode' -ATTR_TRAVEL_TIME_ID = 'TravelTimeID' -ATTR_CURRENT_TIME = 'CurrentTime' -ATTR_AVG_TIME = 'AverageTime' -ATTR_NAME = 'Name' -ATTR_TIME_UPDATED = 'TimeUpdated' -ATTR_DESCRIPTION = 'Description' -ATTRIBUTION = "Data provided by WSDOT" +ICON = 'mdi:car' + +RESOURCE = 'http://www.wsdot.wa.gov/Traffic/api/TravelTimes/' \ + 'TravelTimesREST.svc/GetTravelTimeAsJson' SCAN_INTERVAL = timedelta(minutes=3) @@ -43,16 +46,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): - """Get the WSDOT sensor.""" + """Set up the WSDOT sensor.""" sensors = [] for travel_time in config.get(CONF_TRAVEL_TIMES): - name = (travel_time.get(CONF_NAME) or - travel_time.get(CONF_ID)) + name = (travel_time.get(CONF_NAME) or travel_time.get(CONF_ID)) sensors.append( WashingtonStateTravelTimeSensor( - name, - config.get(CONF_API_KEY), - travel_time.get(CONF_ID))) + name, config.get(CONF_API_KEY), travel_time.get(CONF_ID))) + add_devices(sensors, True) @@ -65,8 +66,6 @@ class WashingtonStateTransportSensor(Entity): can read them and make them available. """ - ICON = 'mdi:car' - def __init__(self, name, access_code): """Initialize the sensor.""" self._data = {} @@ -87,16 +86,12 @@ class WashingtonStateTransportSensor(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" - return self.ICON + return ICON class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): """Travel time sensor from WSDOT.""" - RESOURCE = ('http://www.wsdot.wa.gov/Traffic/api/TravelTimes/' - 'TravelTimesREST.svc/GetTravelTimeAsJson') - ICON = 'mdi:car' - def __init__(self, name, access_code, travel_time_id): """Construct a travel time sensor.""" self._travel_time_id = travel_time_id @@ -104,10 +99,12 @@ class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): def update(self): """Get the latest data from WSDOT.""" - params = {ATTR_ACCESS_CODE: self._access_code, - ATTR_TRAVEL_TIME_ID: self._travel_time_id} + params = { + ATTR_ACCESS_CODE: self._access_code, + ATTR_TRAVEL_TIME_ID: self._travel_time_id, + } - response = requests.get(self.RESOURCE, params, timeout=10) + response = requests.get(RESOURCE, params, timeout=10) if response.status_code != 200: _LOGGER.warning("Invalid response from WSDOT API") else: @@ -118,7 +115,7 @@ class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): def device_state_attributes(self): """Return other details about the sensor state.""" if self._data is not None: - attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + attrs = {ATTR_ATTRIBUTION: CONF_ATTRIBUTION} for key in [ATTR_AVG_TIME, ATTR_NAME, ATTR_DESCRIPTION, ATTR_TRAVEL_TIME_ID]: attrs[key] = self._data.get(key) @@ -129,7 +126,7 @@ class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return 'min' def _parse_wsdot_timestamp(timestamp): @@ -139,5 +136,5 @@ def _parse_wsdot_timestamp(timestamp): # ex: Date(1485040200000-0800) milliseconds, tzone = re.search( r'Date\((\d+)([+-]\d\d)\d\d\)', timestamp).groups() - return datetime.fromtimestamp(int(milliseconds) / 1000, - tz=timezone(timedelta(hours=int(tzone)))) + return datetime.fromtimestamp( + int(milliseconds) / 1000, tz=timezone(timedelta(hours=int(tzone)))) diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index 0375bb1344c..7f2df4bcda9 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -14,13 +14,14 @@ import async_timeout import voluptuous as vol from homeassistant.helpers.typing import HomeAssistantType, ConfigType -from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT +from homeassistant.components import sensor +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, TEMP_FAHRENHEIT, TEMP_CELSIUS, LENGTH_INCHES, LENGTH_KILOMETERS, LENGTH_MILES, LENGTH_FEET, ATTR_ATTRIBUTION) from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity import Entity, async_generate_entity_id +from homeassistant.helpers.entity import Entity from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -626,25 +627,32 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Inclusive(CONF_LONGITUDE, 'coordinates', 'Latitude and longitude must exist together'): cv.longitude, vol.Required(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)]), + vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)]) }) -@asyncio.coroutine -def async_setup_platform(hass: HomeAssistantType, config: ConfigType, - async_add_devices, discovery_info=None): +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up the WUnderground sensor.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + pws_id = config.get(CONF_PWS_ID) rest = WUndergroundData( - hass, config.get(CONF_API_KEY), config.get(CONF_PWS_ID), + hass, config.get(CONF_API_KEY), pws_id, config.get(CONF_LANG), latitude, longitude) + + if pws_id is None: + unique_id_base = "@{:06f},{:06f}".format(longitude, latitude) + else: + # Manually specified weather station, use that for unique_id + unique_id_base = pws_id sensors = [] for variable in config[CONF_MONITORED_CONDITIONS]: - sensors.append(WUndergroundSensor(hass, rest, variable)) + sensors.append(WUndergroundSensor(hass, rest, variable, + unique_id_base)) - yield from rest.async_update() + await rest.async_update() if not rest.data: raise PlatformNotReady @@ -654,7 +662,8 @@ def async_setup_platform(hass: HomeAssistantType, config: ConfigType, class WUndergroundSensor(Entity): """Implementing the WUnderground sensor.""" - def __init__(self, hass: HomeAssistantType, rest, condition): + def __init__(self, hass: HomeAssistantType, rest, condition, + unique_id_base: str): """Initialize the sensor.""" self.rest = rest self._condition = condition @@ -666,8 +675,10 @@ class WUndergroundSensor(Entity): self._entity_picture = None self._unit_of_measurement = self._cfg_expand("unit_of_measurement") self.rest.request_feature(SENSOR_TYPES[condition].feature) - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, "pws_" + condition, hass=hass) + # This is only the suggested entity id, it might get changed by + # the entity registry later. + self.entity_id = sensor.ENTITY_ID_FORMAT.format('pws_' + condition) + self._unique_id = "{},{}".format(unique_id_base, condition) def _cfg_expand(self, what, default=None): """Parse and return sensor data.""" @@ -747,6 +758,11 @@ class WUndergroundSensor(Entity): self._entity_picture = re.sub(r'^http://', 'https://', url, flags=re.IGNORECASE) + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + class WUndergroundData(object): """Get data from WUnderground.""" diff --git a/homeassistant/components/sensor/xiaomi_aqara.py b/homeassistant/components/sensor/xiaomi_aqara.py index 33bbdc32308..3192d0d2f60 100644 --- a/homeassistant/components/sensor/xiaomi_aqara.py +++ b/homeassistant/components/sensor/xiaomi_aqara.py @@ -3,16 +3,18 @@ import logging from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, XiaomiDevice) -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS) _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { - 'temperature': [TEMP_CELSIUS, 'mdi:thermometer'], - 'humidity': ['%', 'mdi:water-percent'], - 'illumination': ['lm', 'mdi:weather-sunset'], - 'lux': ['lx', 'mdi:weather-sunset'], - 'pressure': ['hPa', 'mdi:gauge'] + 'temperature': [TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], + 'humidity': ['%', None, DEVICE_CLASS_HUMIDITY], + 'illumination': ['lm', None, DEVICE_CLASS_ILLUMINANCE], + 'lux': ['lx', None, DEVICE_CLASS_ILLUMINANCE], + 'pressure': ['hPa', 'mdi:gauge', None] } @@ -26,7 +28,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 'temperature', gateway)) devices.append(XiaomiSensor(device, 'Humidity', 'humidity', gateway)) - elif device['model'] == 'weather.v1': + elif device['model'] in ['weather', 'weather.v1']: devices.append(XiaomiSensor(device, 'Temperature', 'temperature', gateway)) devices.append(XiaomiSensor(device, 'Humidity', @@ -36,7 +38,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): elif device['model'] == 'sensor_motion.aq2': devices.append(XiaomiSensor(device, 'Illumination', 'lux', gateway)) - elif device['model'] == 'gateway': + elif device['model'] in ['gateway', 'gateway.v3', 'acpartner.v3']: devices.append(XiaomiSensor(device, 'Illumination', 'illumination', gateway)) add_devices(devices) @@ -66,6 +68,12 @@ class XiaomiSensor(XiaomiDevice): except TypeError: return None + @property + def device_class(self): + """Return the device class of this entity.""" + return SENSOR_TYPES.get(self._data_key)[2] \ + if self._data_key in SENSOR_TYPES else None + @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/sensor/xiaomi_miio.py b/homeassistant/components/sensor/xiaomi_miio.py new file mode 100644 index 00000000000..066dc384007 --- /dev/null +++ b/homeassistant/components/sensor/xiaomi_miio.py @@ -0,0 +1,152 @@ +""" +Support for Xiaomi Mi Air Quality Monitor (PM2.5). + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/sensor.xiaomi_miio/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN) +from homeassistant.exceptions import PlatformNotReady + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Xiaomi Miio Sensor' +DATA_KEY = 'sensor.xiaomi_miio' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] + +ATTR_POWER = 'power' +ATTR_CHARGING = 'charging' +ATTR_BATTERY_LEVEL = 'battery_level' +ATTR_TIME_STATE = 'time_state' +ATTR_MODEL = 'model' + +SUCCESS = ['ok'] + + +# pylint: disable=unused-argument +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the sensor from config.""" + from miio import AirQualityMonitor, DeviceException + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} + + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + + _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + + try: + air_quality_monitor = AirQualityMonitor(host, token) + device_info = air_quality_monitor.info() + model = device_info.model + unique_id = "{}-{}".format(model, device_info.mac_address) + _LOGGER.info("%s %s %s detected", + model, + device_info.firmware_version, + device_info.hardware_version) + device = XiaomiAirQualityMonitor( + name, air_quality_monitor, model, unique_id) + except DeviceException: + raise PlatformNotReady + + hass.data[DATA_KEY][host] = device + async_add_devices([device], update_before_add=True) + + +class XiaomiAirQualityMonitor(Entity): + """Representation of a Xiaomi Air Quality Monitor.""" + + def __init__(self, name, device, model, unique_id): + """Initialize the entity.""" + self._name = name + self._device = device + self._model = model + self._unique_id = unique_id + + self._icon = 'mdi:cloud' + self._unit_of_measurement = 'AQI' + self._available = None + self._state = None + self._state_attrs = { + ATTR_POWER: None, + ATTR_BATTERY_LEVEL: None, + ATTR_CHARGING: None, + ATTR_TIME_STATE: None, + ATTR_MODEL: self._model, + } + + @property + def should_poll(self): + """Poll the miio device.""" + return True + + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of this entity, if any.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon to use for device if any.""" + return self._icon + + @property + def available(self): + """Return true when state is known.""" + return self._available + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state_attrs + + async def async_update(self): + """Fetch state from the miio device.""" + from miio import DeviceException + + try: + state = await self.hass.async_add_job(self._device.status) + _LOGGER.debug("Got new state: %s", state) + + self._available = True + self._state = state.aqi + self._state_attrs.update({ + ATTR_POWER: state.power, + ATTR_CHARGING: state.usb_power, + ATTR_BATTERY_LEVEL: state.battery, + ATTR_TIME_STATE: state.time_state, + }) + + except DeviceException as ex: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/sensor/yweather.py b/homeassistant/components/sensor/yweather.py index df18e086ddd..db66419e54a 100644 --- a/homeassistant/components/sensor/yweather.py +++ b/homeassistant/components/sensor/yweather.py @@ -131,9 +131,12 @@ class YahooWeatherSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - return { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - } + attrs = {ATTR_ATTRIBUTION: CONF_ATTRIBUTION} + + if self._code is not None and "weather" in self._type: + attrs['condition_code'] = self._code + + return attrs def update(self): """Get the latest data from Yahoo! and updates the states.""" diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index 36cdca2e638..53e0e8d0329 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -25,20 +25,32 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return sensor = yield from make_sensor(discovery_info) - async_add_devices([sensor]) + async_add_devices([sensor], update_before_add=True) @asyncio.coroutine def make_sensor(discovery_info): """Create ZHA sensors factory.""" from zigpy.zcl.clusters.measurement import ( - RelativeHumidity, TemperatureMeasurement + RelativeHumidity, TemperatureMeasurement, PressureMeasurement, + IlluminanceMeasurement ) + from zigpy.zcl.clusters.smartenergy import Metering + from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement in_clusters = discovery_info['in_clusters'] if RelativeHumidity.cluster_id in in_clusters: sensor = RelativeHumiditySensor(**discovery_info) elif TemperatureMeasurement.cluster_id in in_clusters: sensor = TemperatureSensor(**discovery_info) + elif PressureMeasurement.cluster_id in in_clusters: + sensor = PressureSensor(**discovery_info) + elif IlluminanceMeasurement.cluster_id in in_clusters: + sensor = IlluminanceMeasurementSensor(**discovery_info) + elif Metering.cluster_id in in_clusters: + sensor = MeteringSensor(**discovery_info) + elif ElectricalMeasurement.cluster_id in in_clusters: + sensor = ElectricalMeasurementSensor(**discovery_info) + return sensor else: sensor = Sensor(**discovery_info) @@ -59,6 +71,11 @@ class Sensor(zha.Entity): value_attribute = 0 min_reportable_change = 1 + @property + def should_poll(self) -> bool: + """State gets pushed from device.""" + return False + @property def state(self) -> str: """Return the state of the entity.""" @@ -71,7 +88,15 @@ class Sensor(zha.Entity): _LOGGER.debug("Attribute updated: %s %s %s", self, attribute, value) if attribute == self.value_attribute: self._state = value - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() + + async def async_update(self): + """Retrieve latest state.""" + result = await zha.safe_read( + list(self._in_clusters.values())[0], + [self.value_attribute] + ) + self._state = result.get(self.value_attribute, self._state) class TemperatureSensor(Sensor): @@ -87,11 +112,13 @@ class TemperatureSensor(Sensor): @property def state(self): """Return the state of the entity.""" - if self._state == 'unknown': - return 'unknown' - celsius = round(float(self._state) / 100, 1) - return convert_temperature( - celsius, TEMP_CELSIUS, self.unit_of_measurement) + if self._state is None: + return None + celsius = self._state / 100 + return round(convert_temperature(celsius, + TEMP_CELSIUS, + self.unit_of_measurement), + 1) class RelativeHumiditySensor(Sensor): @@ -107,7 +134,96 @@ class RelativeHumiditySensor(Sensor): @property def state(self): """Return the state of the entity.""" - if self._state == 'unknown': - return 'unknown' + if self._state is None: + return None return round(float(self._state) / 100, 1) + + +class PressureSensor(Sensor): + """ZHA pressure sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return 'hPa' + + @property + def state(self): + """Return the state of the entity.""" + if self._state is None: + return None + + return round(float(self._state)) + + +class IlluminanceMeasurementSensor(Sensor): + """ZHA lux sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return 'lx' + + @property + def state(self): + """Return the state of the entity.""" + return self._state + + +class MeteringSensor(Sensor): + """ZHA Metering sensor.""" + + value_attribute = 1024 + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return 'W' + + @property + def state(self): + """Return the state of the entity.""" + if self._state is None: + return None + + return round(float(self._state)) + + +class ElectricalMeasurementSensor(Sensor): + """ZHA Electrical Measurement sensor.""" + + value_attribute = 1291 + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return 'W' + + @property + def force_update(self) -> bool: + """Force update this entity.""" + return True + + @property + def state(self): + """Return the state of the entity.""" + if self._state is None: + return None + + return round(float(self._state) / 10, 1) + + @property + def should_poll(self) -> bool: + """Poll state from device.""" + return True + + async def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug("%s async_update", self.entity_id) + + result = await zha.safe_read( + self._endpoint.electrical_measurement, + ['active_power'], + allow_cache=False) + self._state = result.get('active_power', self._state) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 519d3b98704..c0279ef1d0f 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -395,6 +395,18 @@ snips: intent_filter: description: Optional Array of Strings - A list of intents names to restrict the NLU resolution to on the first query. example: turnOnLights, turnOffLights + feedback_on: + description: Turns feedback sounds on. + fields: + site_id: + description: Site to turn sounds on, defaults to all sites (optional) + example: bedroom + feedback_off: + description: Turns feedback sounds off. + fields: + site_id: + description: Site to turn sounds on, defaults to all sites (optional) + example: bedroom input_boolean: toggle: @@ -544,3 +556,17 @@ xiaomi_aqara: device_id: description: Hardware address of the device to remove. example: 158d0000000000 + +shopping_list: + add_item: + description: Adds an item to the shopping list. + fields: + name: + description: The name of the item to add. + example: Beer + complete_item: + description: Marks an item as completed in the shopping list. It does not remove the item. + fields: + name: + description: The name of the item to mark as completed. + example: Beer diff --git a/homeassistant/components/shell_command.py b/homeassistant/components/shell_command.py index ca33666d1f3..10a6c350b7c 100644 --- a/homeassistant/components/shell_command.py +++ b/homeassistant/components/shell_command.py @@ -68,8 +68,9 @@ def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: cmd, loop=hass.loop, stdin=None, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL) + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) else: # Template used. Break into list and use create_subprocess_exec # (which uses shell=False) for security @@ -80,12 +81,19 @@ def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: *shlexed_cmd, loop=hass.loop, stdin=None, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL) + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) process = yield from create_process - yield from process.communicate() + stdout_data, stderr_data = yield from process.communicate() + if stdout_data: + _LOGGER.debug("Stdout of command: `%s`, return code: %s:\n%s", + cmd, process.returncode, stdout_data) + if stderr_data: + _LOGGER.debug("Stderr of command: `%s`, return code: %s:\n%s", + cmd, process.returncode, stderr_data) if process.returncode != 0: _LOGGER.exception("Error running command: `%s`, return code: %s", cmd, process.returncode) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 0ca0fef6e06..f113561429a 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -14,6 +14,8 @@ from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json +ATTR_NAME = 'name' + DOMAIN = 'shopping_list' DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -23,20 +25,57 @@ INTENT_ADD_ITEM = 'HassShoppingListAddItem' INTENT_LAST_ITEMS = 'HassShoppingListLastItems' ITEM_UPDATE_SCHEMA = vol.Schema({ 'complete': bool, - 'name': str, + ATTR_NAME: str, }) PERSISTENCE = '.shopping_list.json' +SERVICE_ADD_ITEM = 'add_item' +SERVICE_COMPLETE_ITEM = 'complete_item' + +SERVICE_ITEM_SCHEMA = vol.Schema({ + vol.Required(ATTR_NAME): vol.Any(None, cv.string) +}) + @asyncio.coroutine def async_setup(hass, config): """Initialize the shopping list.""" + @asyncio.coroutine + def add_item_service(call): + """Add an item with `name`.""" + data = hass.data[DOMAIN] + name = call.data.get(ATTR_NAME) + if name is not None: + data.async_add(name) + + @asyncio.coroutine + def complete_item_service(call): + """Mark the item provided via `name` as completed.""" + data = hass.data[DOMAIN] + name = call.data.get(ATTR_NAME) + if name is None: + return + try: + item = [item for item in data.items if item['name'] == name][0] + except IndexError: + _LOGGER.error("Removing of item failed: %s cannot be found", name) + else: + data.async_update(item['id'], {'name': name, 'complete': True}) + data = hass.data[DOMAIN] = ShoppingData(hass) yield from data.async_load() intent.async_register(hass, AddItemIntent()) intent.async_register(hass, ListTopItemsIntent()) + hass.services.async_register( + DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, + schema=SERVICE_ITEM_SCHEMA + ) + hass.http.register_view(ShoppingListView) hass.http.register_view(CreateShoppingListItemView) hass.http.register_view(UpdateShoppingListItemView) diff --git a/homeassistant/components/skybell.py b/homeassistant/components/skybell.py index 854abdda7bc..3f27c91e7c5 100644 --- a/homeassistant/components/skybell.py +++ b/homeassistant/components/skybell.py @@ -14,7 +14,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['skybellpy==0.1.1'] +REQUIREMENTS = ['skybellpy==0.1.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smappee.py b/homeassistant/components/smappee.py index 0111e0437fb..b35cd8cf5a8 100644 --- a/homeassistant/components/smappee.py +++ b/homeassistant/components/smappee.py @@ -110,6 +110,7 @@ class Smappee(object): self.locations = {} self.info = {} self.consumption = {} + self.sensor_consumption = {} self.instantaneous = {} if self._remote_active or self._local_active: @@ -124,11 +125,22 @@ class Smappee(object): for location in service_locations: location_id = location.get('serviceLocationId') if location_id is not None: + self.sensor_consumption[location_id] = {} self.locations[location_id] = location.get('name') self.info[location_id] = self._smappy \ .get_service_location_info(location_id) _LOGGER.debug("Remote info %s %s", - self.locations, self.info) + self.locations, self.info[location_id]) + + for sensors in self.info[location_id].get('sensors'): + sensor_id = sensors.get('id') + self.sensor_consumption[location_id]\ + .update({sensor_id: self.get_sensor_consumption( + location_id, sensor_id, + aggregation=3, delta=1440)}) + _LOGGER.debug("Remote sensors %s %s", + self.locations, + self.sensor_consumption[location_id]) self.consumption[location_id] = self.get_consumption( location_id, aggregation=3, delta=1440) @@ -190,7 +202,8 @@ class Smappee(object): "Error getting comsumption from Smappee cloud. (%s)", error) - def get_sensor_consumption(self, location_id, sensor_id): + def get_sensor_consumption(self, location_id, sensor_id, + aggregation, delta): """Update data from Smappee.""" # Start & End accept epoch (in milliseconds), # datetime and pandas timestamps @@ -203,13 +216,13 @@ class Smappee(object): if not self.is_remote_active: return - start = datetime.utcnow() - timedelta(minutes=30) end = datetime.utcnow() + start = end - timedelta(minutes=delta) try: return self._smappy.get_sensor_consumption(location_id, sensor_id, start, - end, 1) + end, aggregation) except RequestException as error: _LOGGER.error( "Error getting comsumption from Smappee cloud. (%s)", @@ -264,7 +277,7 @@ class Smappee(object): return True def active_power(self): - """Get sum of all instantanious active power values from local hub.""" + """Get sum of all instantaneous active power values from local hub.""" if not self.is_local_active: return diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index d085b1279cb..4f50c6beaaa 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -4,13 +4,13 @@ Support for Snips on-device ASR and NLU. For more details about this component, please refer to the documentation at https://home-assistant.io/components/snips/ """ -import asyncio import json import logging from datetime import timedelta import voluptuous as vol +from homeassistant.core import callback from homeassistant.helpers import intent, config_validation as cv import homeassistant.components.mqtt as mqtt @@ -19,11 +19,18 @@ DEPENDENCIES = ['mqtt'] CONF_INTENTS = 'intents' CONF_ACTION = 'action' +CONF_FEEDBACK = 'feedback_sounds' +CONF_PROBABILITY = 'probability_threshold' +CONF_SITE_IDS = 'site_ids' SERVICE_SAY = 'say' SERVICE_SAY_ACTION = 'say_action' +SERVICE_FEEDBACK_ON = 'feedback_on' +SERVICE_FEEDBACK_OFF = 'feedback_off' INTENT_TOPIC = 'hermes/intent/#' +FEEDBACK_ON_TOPIC = 'hermes/feedback/sound/toggleOn' +FEEDBACK_OFF_TOPIC = 'hermes/feedback/sound/toggleOff' ATTR_TEXT = 'text' ATTR_SITE_ID = 'site_id' @@ -34,7 +41,12 @@ ATTR_INTENT_FILTER = 'intent_filter' _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: {} + DOMAIN: vol.Schema({ + vol.Optional(CONF_FEEDBACK): cv.boolean, + vol.Optional(CONF_PROBABILITY, default=0): vol.Coerce(float), + vol.Optional(CONF_SITE_IDS, default=['default']): + vol.All(cv.ensure_list, [cv.string]), + }), }, extra=vol.ALLOW_EXTRA) INTENT_SCHEMA = vol.Schema({ @@ -57,7 +69,6 @@ SERVICE_SCHEMA_SAY = vol.Schema({ vol.Optional(ATTR_SITE_ID, default='default'): str, vol.Optional(ATTR_CUSTOM_DATA, default=''): str }) - SERVICE_SCHEMA_SAY_ACTION = vol.Schema({ vol.Required(ATTR_TEXT): str, vol.Optional(ATTR_SITE_ID, default='default'): str, @@ -65,13 +76,31 @@ SERVICE_SCHEMA_SAY_ACTION = vol.Schema({ vol.Optional(ATTR_CAN_BE_ENQUEUED, default=True): cv.boolean, vol.Optional(ATTR_INTENT_FILTER): vol.All(cv.ensure_list), }) +SERVICE_SCHEMA_FEEDBACK = vol.Schema({ + vol.Optional(ATTR_SITE_ID, default='default'): str +}) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Activate Snips component.""" - @asyncio.coroutine - def message_received(topic, payload, qos): + @callback + def async_set_feedback(site_ids, state): + """Set Feedback sound state.""" + site_ids = (site_ids if site_ids + else config[DOMAIN].get(CONF_SITE_IDS)) + topic = (FEEDBACK_ON_TOPIC if state + else FEEDBACK_OFF_TOPIC) + for site_id in site_ids: + payload = json.dumps({'siteId': site_id}) + hass.components.mqtt.async_publish( + FEEDBACK_ON_TOPIC, None, qos=0, retain=False) + hass.components.mqtt.async_publish( + topic, payload, qos=int(state), retain=state) + + if CONF_FEEDBACK in config[DOMAIN]: + async_set_feedback(None, config[DOMAIN][CONF_FEEDBACK]) + + async def message_received(topic, payload, qos): """Handle new messages on MQTT.""" _LOGGER.debug("New intent: %s", payload) @@ -81,6 +110,13 @@ def async_setup(hass, config): _LOGGER.error('Received invalid JSON: %s', payload) return + if (request['intent']['probability'] + < config[DOMAIN].get(CONF_PROBABILITY)): + _LOGGER.warning("Intent below probaility threshold %s < %s", + request['intent']['probability'], + config[DOMAIN].get(CONF_PROBABILITY)) + return + try: request = INTENT_SCHEMA(request) except vol.Invalid as err: @@ -95,9 +131,11 @@ def async_setup(hass, config): slots = {} for slot in request.get('slots', []): slots[slot['slotName']] = {'value': resolve_slot_values(slot)} + slots['site_id'] = {'value': request.get('siteId')} + slots['probability'] = {'value': request['intent']['probability']} try: - intent_response = yield from intent.async_handle( + intent_response = await intent.async_handle( hass, DOMAIN, intent_type, slots, request['input']) if 'plain' in intent_response.speech: snips_response = intent_response.speech['plain']['speech'] @@ -115,11 +153,10 @@ def async_setup(hass, config): mqtt.async_publish(hass, 'hermes/dialogueManager/endSession', json.dumps(notification)) - yield from hass.components.mqtt.async_subscribe( + await hass.components.mqtt.async_subscribe( INTENT_TOPIC, message_received) - @asyncio.coroutine - def snips_say(call): + async def snips_say(call): """Send a Snips notification message.""" notification = {'siteId': call.data.get(ATTR_SITE_ID, 'default'), 'customData': call.data.get(ATTR_CUSTOM_DATA, ''), @@ -129,8 +166,7 @@ def async_setup(hass, config): json.dumps(notification)) return - @asyncio.coroutine - def snips_say_action(call): + async def snips_say_action(call): """Send a Snips action message.""" notification = {'siteId': call.data.get(ATTR_SITE_ID, 'default'), 'customData': call.data.get(ATTR_CUSTOM_DATA, ''), @@ -144,12 +180,26 @@ def async_setup(hass, config): json.dumps(notification)) return + async def feedback_on(call): + """Turn feedback sounds on.""" + async_set_feedback(call.data.get(ATTR_SITE_ID), True) + + async def feedback_off(call): + """Turn feedback sounds off.""" + async_set_feedback(call.data.get(ATTR_SITE_ID), False) + hass.services.async_register( DOMAIN, SERVICE_SAY, snips_say, schema=SERVICE_SCHEMA_SAY) hass.services.async_register( DOMAIN, SERVICE_SAY_ACTION, snips_say_action, schema=SERVICE_SCHEMA_SAY_ACTION) + hass.services.async_register( + DOMAIN, SERVICE_FEEDBACK_ON, feedback_on, + schema=SERVICE_SCHEMA_FEEDBACK) + hass.services.async_register( + DOMAIN, SERVICE_FEEDBACK_OFF, feedback_off, + schema=SERVICE_SCHEMA_FEEDBACK) return True diff --git a/homeassistant/components/spaceapi.py b/homeassistant/components/spaceapi.py new file mode 100644 index 00000000000..eaf1508071a --- /dev/null +++ b/homeassistant/components/spaceapi.py @@ -0,0 +1,175 @@ +""" +Support for the SpaceAPI. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/spaceapi/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_ICON, ATTR_LATITUDE, ATTR_LOCATION, ATTR_LONGITUDE, + ATTR_STATE, ATTR_UNIT_OF_MEASUREMENT, CONF_ADDRESS, CONF_EMAIL, + CONF_ENTITY_ID, CONF_SENSORS, CONF_STATE, CONF_URL) +import homeassistant.core as ha +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +ATTR_ADDRESS = 'address' +ATTR_API = 'api' +ATTR_CLOSE = 'close' +ATTR_CONTACT = 'contact' +ATTR_ISSUE_REPORT_CHANNELS = 'issue_report_channels' +ATTR_LASTCHANGE = 'lastchange' +ATTR_LOGO = 'logo' +ATTR_NAME = 'name' +ATTR_OPEN = 'open' +ATTR_SENSORS = 'sensors' +ATTR_SPACE = 'space' +ATTR_UNIT = 'unit' +ATTR_URL = 'url' +ATTR_VALUE = 'value' + +CONF_CONTACT = 'contact' +CONF_HUMIDITY = 'humidity' +CONF_ICON_CLOSED = 'icon_closed' +CONF_ICON_OPEN = 'icon_open' +CONF_ICONS = 'icons' +CONF_IRC = 'irc' +CONF_ISSUE_REPORT_CHANNELS = 'issue_report_channels' +CONF_LOCATION = 'location' +CONF_LOGO = 'logo' +CONF_MAILING_LIST = 'mailing_list' +CONF_PHONE = 'phone' +CONF_SPACE = 'space' +CONF_TEMPERATURE = 'temperature' +CONF_TWITTER = 'twitter' + +DATA_SPACEAPI = 'data_spaceapi' +DEPENDENCIES = ['http'] +DOMAIN = 'spaceapi' + +ISSUE_REPORT_CHANNELS = [CONF_EMAIL, CONF_IRC, CONF_MAILING_LIST, CONF_TWITTER] + +SENSOR_TYPES = [CONF_HUMIDITY, CONF_TEMPERATURE] +SPACEAPI_VERSION = 0.13 + +URL_API_SPACEAPI = '/api/spaceapi' + +LOCATION_SCHEMA = vol.Schema({ + vol.Optional(CONF_ADDRESS): cv.string, +}, required=True) + +CONTACT_SCHEMA = vol.Schema({ + vol.Optional(CONF_EMAIL): cv.string, + vol.Optional(CONF_IRC): cv.string, + vol.Optional(CONF_MAILING_LIST): cv.string, + vol.Optional(CONF_PHONE): cv.string, + vol.Optional(CONF_TWITTER): cv.string, +}, required=False) + +STATE_SCHEMA = vol.Schema({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Inclusive(CONF_ICON_CLOSED, CONF_ICONS): cv.url, + vol.Inclusive(CONF_ICON_OPEN, CONF_ICONS): cv.url, +}, required=False) + +SENSOR_SCHEMA = vol.Schema( + {vol.In(SENSOR_TYPES): [cv.entity_id]} +) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_CONTACT): CONTACT_SCHEMA, + vol.Required(CONF_ISSUE_REPORT_CHANNELS): + vol.All(cv.ensure_list, [vol.In(ISSUE_REPORT_CHANNELS)]), + vol.Required(CONF_LOCATION): LOCATION_SCHEMA, + vol.Required(CONF_LOGO): cv.url, + vol.Required(CONF_SPACE): cv.string, + vol.Required(CONF_STATE): STATE_SCHEMA, + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_SENSORS): SENSOR_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Register the SpaceAPI with the HTTP interface.""" + hass.data[DATA_SPACEAPI] = config[DOMAIN] + hass.http.register_view(APISpaceApiView) + + return True + + +class APISpaceApiView(HomeAssistantView): + """View to provide details according to the SpaceAPI.""" + + url = URL_API_SPACEAPI + name = 'api:spaceapi' + + @ha.callback + def get(self, request): + """Get SpaceAPI data.""" + hass = request.app['hass'] + spaceapi = dict(hass.data[DATA_SPACEAPI]) + is_sensors = spaceapi.get('sensors') + + location = { + ATTR_ADDRESS: spaceapi[ATTR_LOCATION][CONF_ADDRESS], + ATTR_LATITUDE: hass.config.latitude, + ATTR_LONGITUDE: hass.config.longitude, + } + + state_entity = spaceapi['state'][ATTR_ENTITY_ID] + space_state = hass.states.get(state_entity) + + if space_state is not None: + state = { + ATTR_OPEN: False if space_state.state == 'off' else True, + ATTR_LASTCHANGE: + dt_util.as_timestamp(space_state.last_updated), + } + else: + state = {ATTR_OPEN: 'null', ATTR_LASTCHANGE: 0} + + try: + state[ATTR_ICON] = { + ATTR_OPEN: spaceapi['state'][CONF_ICON_OPEN], + ATTR_CLOSE: spaceapi['state'][CONF_ICON_CLOSED], + } + except KeyError: + pass + + data = { + ATTR_API: SPACEAPI_VERSION, + ATTR_CONTACT: spaceapi[CONF_CONTACT], + ATTR_ISSUE_REPORT_CHANNELS: spaceapi[CONF_ISSUE_REPORT_CHANNELS], + ATTR_LOCATION: location, + ATTR_LOGO: spaceapi[CONF_LOGO], + ATTR_SPACE: spaceapi[CONF_SPACE], + ATTR_STATE: state, + ATTR_URL: spaceapi[CONF_URL], + } + + if is_sensors is not None: + sensors = {} + for sensor_type in is_sensors: + sensors[sensor_type] = [] + for sensor in spaceapi['sensors'][sensor_type]: + sensor_state = hass.states.get(sensor) + unit = sensor_state.attributes[ATTR_UNIT_OF_MEASUREMENT] + value = sensor_state.state + sensor_data = { + ATTR_LOCATION: spaceapi[CONF_SPACE], + ATTR_NAME: sensor_state.name, + ATTR_UNIT: unit, + ATTR_VALUE: value, + } + sensors[sensor_type].append(sensor_data) + data[ATTR_SENSORS] = sensors + + return self.json(data) diff --git a/homeassistant/components/switch/amcrest.py b/homeassistant/components/switch/amcrest.py new file mode 100755 index 00000000000..0b93bc98b10 --- /dev/null +++ b/homeassistant/components/switch/amcrest.py @@ -0,0 +1,92 @@ +""" +Support for toggling Amcrest IP camera settings. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.amcrest/ +""" +import asyncio +import logging + +from homeassistant.components.amcrest import DATA_AMCREST, SWITCHES +from homeassistant.const import ( + CONF_NAME, CONF_SWITCHES, STATE_OFF, STATE_ON) +from homeassistant.helpers.entity import ToggleEntity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['amcrest'] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the IP Amcrest camera switch platform.""" + if discovery_info is None: + return + + name = discovery_info[CONF_NAME] + switches = discovery_info[CONF_SWITCHES] + camera = hass.data[DATA_AMCREST][name].device + + all_switches = [] + + for setting in switches: + all_switches.append(AmcrestSwitch(setting, camera)) + + async_add_devices(all_switches, True) + + +class AmcrestSwitch(ToggleEntity): + """Representation of an Amcrest IP camera switch.""" + + def __init__(self, setting, camera): + """Initialize the Amcrest switch.""" + self._setting = setting + self._camera = camera + self._name = SWITCHES[setting][0] + self._icon = SWITCHES[setting][1] + self._state = None + + @property + def name(self): + """Return the name of the switch if any.""" + return self._name + + @property + def state(self): + """Return the state of the switch.""" + return self._state + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state == STATE_ON + + def turn_on(self, **kwargs): + """Turn setting on.""" + if self._setting == 'motion_detection': + self._camera.motion_detection = 'true' + elif self._setting == 'motion_recording': + self._camera.motion_recording = 'true' + + def turn_off(self, **kwargs): + """Turn setting off.""" + if self._setting == 'motion_detection': + self._camera.motion_detection = 'false' + elif self._setting == 'motion_recording': + self._camera.motion_recording = 'false' + + def update(self): + """Update setting state.""" + _LOGGER.debug("Polling state for setting: %s ", self._name) + + if self._setting == 'motion_detection': + detection = self._camera.is_motion_detector_on() + elif self._setting == 'motion_recording': + detection = self._camera.is_record_on_motion_detection() + + self._state = STATE_ON if detection else STATE_OFF + + @property + def icon(self): + """Return the icon for the switch.""" + return self._icon diff --git a/homeassistant/components/switch/arest.py b/homeassistant/components/switch/arest.py index 6e31694fd2d..fd72d0728a0 100644 --- a/homeassistant/components/switch/arest.py +++ b/homeassistant/components/switch/arest.py @@ -18,11 +18,13 @@ _LOGGER = logging.getLogger(__name__) CONF_FUNCTIONS = 'functions' CONF_PINS = 'pins' +CONF_INVERT = 'invert' DEFAULT_NAME = 'aREST switch' PIN_FUNCTION_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -54,7 +56,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for pinnum, pin in pins.items(): dev.append(ArestSwitchPin( resource, config.get(CONF_NAME, response.json()[CONF_NAME]), - pin.get(CONF_NAME), pinnum)) + pin.get(CONF_NAME), pinnum, pin.get(CONF_INVERT))) functions = config.get(CONF_FUNCTIONS) for funcname, func in functions.items(): @@ -152,10 +154,11 @@ class ArestSwitchFunction(ArestSwitchBase): class ArestSwitchPin(ArestSwitchBase): """Representation of an aREST switch. Based on digital I/O.""" - def __init__(self, resource, location, name, pin): + def __init__(self, resource, location, name, pin, invert): """Initialize the switch.""" super().__init__(resource, location, name) self._pin = pin + self.invert = invert request = requests.get( '{}/mode/{}/o'.format(self._resource, self._pin), timeout=10) @@ -165,8 +168,11 @@ class ArestSwitchPin(ArestSwitchBase): def turn_on(self, **kwargs): """Turn the device on.""" + turn_on_payload = int(not self.invert) request = requests.get( - '{}/digital/{}/1'.format(self._resource, self._pin), timeout=10) + '{}/digital/{}/{}'.format(self._resource, self._pin, + turn_on_payload), + timeout=10) if request.status_code == 200: self._state = True else: @@ -175,8 +181,11 @@ class ArestSwitchPin(ArestSwitchBase): def turn_off(self, **kwargs): """Turn the device off.""" + turn_off_payload = int(self.invert) request = requests.get( - '{}/digital/{}/0'.format(self._resource, self._pin), timeout=10) + '{}/digital/{}/{}'.format(self._resource, self._pin, + turn_off_payload), + timeout=10) if request.status_code == 200: self._state = False else: @@ -188,7 +197,8 @@ class ArestSwitchPin(ArestSwitchBase): try: request = requests.get( '{}/digital/{}'.format(self._resource, self._pin), timeout=10) - self._state = request.json()['return_value'] != 0 + status_value = int(self.invert) + self._state = request.json()['return_value'] != status_value self._available = True except requests.exceptions.ConnectionError: _LOGGER.warning("No route to device %s", self._resource) diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 38888733ba6..46002112177 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -14,17 +14,15 @@ import socket import voluptuous as vol from homeassistant.components.switch import ( - DOMAIN, PLATFORM_SCHEMA, SwitchDevice) + DOMAIN, PLATFORM_SCHEMA, SwitchDevice, ENTITY_ID_FORMAT) from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_FRIENDLY_NAME, CONF_HOST, CONF_MAC, CONF_SWITCHES, CONF_TIMEOUT, CONF_TYPE) import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.util import Throttle, slugify from homeassistant.util.dt import utcnow -REQUIREMENTS = [ - 'https://github.com/balloob/python-broadlink/archive/' - '3580ff2eaccd267846f14246d6ede6e30671f7c6.zip#broadlink==0.5.1'] +REQUIREMENTS = ['broadlink==0.9.0'] _LOGGER = logging.getLogger(__name__) @@ -142,7 +140,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return slots['slot_{}'.format(slot)] if switch_type in RM_TYPES: - broadlink_device = broadlink.rm((ip_addr, 80), mac_addr) + broadlink_device = broadlink.rm((ip_addr, 80), mac_addr, None) hass.services.register(DOMAIN, SERVICE_LEARN + '_' + ip_addr.replace('.', '_'), _learn_command) hass.services.register(DOMAIN, SERVICE_SEND + '_' + @@ -152,6 +150,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for object_id, device_config in devices.items(): switches.append( BroadlinkRMSwitch( + object_id, device_config.get(CONF_FRIENDLY_NAME, object_id), broadlink_device, device_config.get(CONF_COMMAND_ON), @@ -159,14 +158,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): ) ) elif switch_type in SP1_TYPES: - broadlink_device = broadlink.sp1((ip_addr, 80), mac_addr) + broadlink_device = broadlink.sp1((ip_addr, 80), mac_addr, None) switches = [BroadlinkSP1Switch(friendly_name, broadlink_device)] elif switch_type in SP2_TYPES: - broadlink_device = broadlink.sp2((ip_addr, 80), mac_addr) + broadlink_device = broadlink.sp2((ip_addr, 80), mac_addr, None) switches = [BroadlinkSP2Switch(friendly_name, broadlink_device)] elif switch_type in MP1_TYPES: switches = [] - broadlink_device = broadlink.mp1((ip_addr, 80), mac_addr) + broadlink_device = broadlink.mp1((ip_addr, 80), mac_addr, None) parent_device = BroadlinkMP1Switch(broadlink_device) for i in range(1, 5): slot = BroadlinkMP1Slot( @@ -186,8 +185,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class BroadlinkRMSwitch(SwitchDevice): """Representation of an Broadlink switch.""" - def __init__(self, friendly_name, device, command_on, command_off): + def __init__(self, name, friendly_name, device, command_on, command_off): """Initialize the switch.""" + self.entity_id = ENTITY_ID_FORMAT.format(slugify(name)) self._name = friendly_name self._state = False self._command_on = b64decode(command_on) if command_on else None @@ -257,7 +257,7 @@ class BroadlinkSP1Switch(BroadlinkRMSwitch): def __init__(self, friendly_name, device): """Initialize the switch.""" - super().__init__(friendly_name, device, None, None) + super().__init__(friendly_name, friendly_name, device, None, None) self._command_on = 1 self._command_off = 0 @@ -313,7 +313,7 @@ class BroadlinkMP1Slot(BroadlinkRMSwitch): def __init__(self, friendly_name, device, slot, parent_device): """Initialize the slot of switch.""" - super().__init__(friendly_name, device, None, None) + super().__init__(friendly_name, friendly_name, device, None, None) self._command_on = 1 self._command_off = 0 self._slot = slot diff --git a/homeassistant/components/switch/deluge.py b/homeassistant/components/switch/deluge.py index 30287a2669e..da0b3bf3228 100644 --- a/homeassistant/components/switch/deluge.py +++ b/homeassistant/components/switch/deluge.py @@ -9,15 +9,16 @@ import logging import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.exceptions import PlatformNotReady from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON) from homeassistant.helpers.entity import ToggleEntity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['deluge-client==1.0.5'] +REQUIREMENTS = ['deluge-client==1.4.0'] -_LOGGING = logging.getLogger(__name__) +_LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Deluge Switch' DEFAULT_PORT = 58846 @@ -46,8 +47,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): try: deluge_api.connect() except ConnectionRefusedError: - _LOGGING.error("Connection to Deluge Daemon failed") - return + _LOGGER.error("Connection to Deluge Daemon failed") + raise PlatformNotReady add_devices([DelugeSwitch(deluge_api, name)]) @@ -60,6 +61,7 @@ class DelugeSwitch(ToggleEntity): self._name = name self.deluge_client = deluge_client self._state = STATE_OFF + self._available = False @property def name(self): @@ -76,18 +78,32 @@ class DelugeSwitch(ToggleEntity): """Return true if device is on.""" return self._state == STATE_ON + @property + def available(self): + """Return true if device is available.""" + return self._available + def turn_on(self, **kwargs): """Turn the device on.""" - self.deluge_client.call('core.resume_all_torrents') + torrent_ids = self.deluge_client.call('core.get_session_state') + self.deluge_client.call('core.resume_torrent', torrent_ids) def turn_off(self, **kwargs): """Turn the device off.""" - self.deluge_client.call('core.pause_all_torrents') + torrent_ids = self.deluge_client.call('core.get_session_state') + self.deluge_client.call('core.pause_torrent', torrent_ids) def update(self): """Get the latest data from deluge and updates the state.""" - torrent_list = self.deluge_client.call('core.get_torrents_status', {}, - ['paused']) + from deluge_client import FailedToReconnectException + try: + torrent_list = self.deluge_client.call('core.get_torrents_status', + {}, ['paused']) + self._available = True + except FailedToReconnectException: + _LOGGER.error("Connection to Deluge Daemon Lost") + self._available = False + return for torrent in torrent_list.values(): item = torrent.popitem() if not item[1]: diff --git a/homeassistant/components/switch/doorbird.py b/homeassistant/components/switch/doorbird.py index 4ab8eea6ec4..9886b3a586d 100644 --- a/homeassistant/components/switch/doorbird.py +++ b/homeassistant/components/switch/doorbird.py @@ -22,6 +22,14 @@ SWITCHES = { }, "time": datetime.timedelta(seconds=3) }, + "open_door_2": { + "name": "Open Door 2", + "icon": { + True: "lock-open", + False: "lock" + }, + "time": datetime.timedelta(seconds=3) + }, "light_on": { "name": "Light On", "icon": { @@ -80,6 +88,8 @@ class DoorBirdSwitch(SwitchDevice): """Power the relay.""" if self._switch == "open_door": self._state = self._device.open_door() + elif self._switch == "open_door_2": + self._state = self._device.open_door(2) elif self._switch == "light_on": self._state = self._device.turn_light_on() diff --git a/homeassistant/components/switch/edimax.py b/homeassistant/components/switch/edimax.py index 50b5ba93b85..40ebb54b603 100644 --- a/homeassistant/components/switch/edimax.py +++ b/homeassistant/components/switch/edimax.py @@ -83,13 +83,12 @@ class SmartPlugSwitch(SwitchDevice): def update(self): """Update edimax switch.""" try: - self._now_power = float(self.smartplug.now_power) / 1000000.0 + self._now_power = float(self.smartplug.now_power) except (TypeError, ValueError): self._now_power = None try: - self._now_energy_day = (float(self.smartplug.now_energy_day) / - 1000.0) + self._now_energy_day = float(self.smartplug.now_energy_day) except (TypeError, ValueError): self._now_energy_day = None diff --git a/homeassistant/components/switch/eufy.py b/homeassistant/components/switch/eufy.py new file mode 100644 index 00000000000..891525d3979 --- /dev/null +++ b/homeassistant/components/switch/eufy.py @@ -0,0 +1,73 @@ +""" +Support for Eufy switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.eufy/ +""" +import logging + +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = ['eufy'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Eufy switches.""" + if discovery_info is None: + return + add_devices([EufySwitch(discovery_info)], True) + + +class EufySwitch(SwitchDevice): + """Representation of a Eufy switch.""" + + def __init__(self, device): + """Initialize the light.""" + # pylint: disable=import-error + import lakeside + + self._state = None + self._name = device['name'] + self._address = device['address'] + self._code = device['code'] + self._type = device['type'] + self._switch = lakeside.switch(self._address, self._code, self._type) + self._switch.connect() + + def update(self): + """Synchronise state from the switch.""" + self._switch.update() + self._state = self._switch.power + + @property + def unique_id(self): + """Return the ID of this light.""" + return self._address + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the specified switch on.""" + try: + self._switch.set_state(True) + except BrokenPipeError: + self._switch.connect() + self._switch.set_state(power=True) + + def turn_off(self, **kwargs): + """Turn the specified switch off.""" + try: + self._switch.set_state(False) + except BrokenPipeError: + self._switch.connect() + self._switch.set_state(False) diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index acc0c3ac423..21689dcca0f 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -21,7 +21,7 @@ from homeassistant.helpers.event import track_time_change from homeassistant.helpers.sun import get_astral_event_date from homeassistant.util import slugify from homeassistant.util.color import ( - color_temperature_to_rgb, color_RGB_to_xy, + color_temperature_to_rgb, color_RGB_to_xy_brightness, color_temperature_kelvin_to_mired) from homeassistant.util.dt import now as dt_now @@ -72,7 +72,8 @@ def set_lights_xy(hass, lights, x_val, y_val, brightness, transition): turn_on(hass, light, xy_color=[x_val, y_val], brightness=brightness, - transition=transition) + transition=transition, + white_value=brightness) def set_lights_temp(hass, lights, mired, brightness, transition): @@ -234,7 +235,7 @@ class FluxSwitch(SwitchDevice): else: temp = self._sunset_colortemp + temp_offset rgb = color_temperature_to_rgb(temp) - x_val, y_val, b_val = color_RGB_to_xy(*rgb) + x_val, y_val, b_val = color_RGB_to_xy_brightness(*rgb) brightness = self._brightness if self._brightness else b_val if self._disable_brightness_adjust: brightness = None diff --git a/homeassistant/components/switch/fritzbox.py b/homeassistant/components/switch/fritzbox.py new file mode 100755 index 00000000000..65a1aa6aabc --- /dev/null +++ b/homeassistant/components/switch/fritzbox.py @@ -0,0 +1,104 @@ +""" +Support for AVM Fritz!Box smarthome switch devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/switch.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_LOCKED) +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +DEPENDENCIES = ['fritzbox'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_TOTAL_CONSUMPTION_UNIT = 'total_consumption_unit' +ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = 'kWh' + +ATTR_TEMPERATURE_UNIT = 'temperature_unit' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Fritzbox smarthome switch 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_switch: + devices.append(FritzboxSwitch(device, fritz)) + + add_devices(devices) + + +class FritzboxSwitch(SwitchDevice): + """The switch class for Fritzbox switches.""" + + def __init__(self, device, fritz): + """Initialize the switch.""" + self._device = device + self._fritz = fritz + + @property + def available(self): + """Return if switch is available.""" + return self._device.present + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def is_on(self): + """Return true if the switch is on.""" + return self._device.switch_state + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._device.set_switch_state_on() + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._device.set_switch_state_off() + + def update(self): + """Get latest data and states from the device.""" + try: + self._device.update() + except requests.exceptions.HTTPError as ex: + _LOGGER.warning("Fritzhome connection error: %s", ex) + self._fritz.login() + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attrs = {} + attrs[ATTR_STATE_DEVICE_LOCKED] = self._device.device_lock + attrs[ATTR_STATE_LOCKED] = self._device.lock + + if self._device.has_powermeter: + attrs[ATTR_TOTAL_CONSUMPTION] = "{:.3f}".format( + (self._device.energy or 0.0) / 1000) + attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = \ + ATTR_TOTAL_CONSUMPTION_UNIT_VALUE + if self._device.has_temperature_sensor: + attrs[ATTR_TEMPERATURE] = \ + str(self.hass.config.units.temperature( + self._device.temperature, TEMP_CELSIUS)) + attrs[ATTR_TEMPERATURE_UNIT] = \ + self.hass.config.units.temperature_unit + return attrs + + @property + def current_power_w(self): + """Return the current power usage in W.""" + return self._device.power / 1000 diff --git a/homeassistant/components/switch/hive.py b/homeassistant/components/switch/hive.py index 67ebe95ba8e..49fc9696b5e 100644 --- a/homeassistant/components/switch/hive.py +++ b/homeassistant/components/switch/hive.py @@ -28,6 +28,7 @@ class HiveDevicePlug(SwitchDevice): self.node_name = hivedevice["Hive_NodeName"] self.device_type = hivedevice["HA_DeviceType"] self.session = hivesession + self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) self.session.entities.append(self) @@ -42,6 +43,11 @@ class HiveDevicePlug(SwitchDevice): """Return the name of this Switch device if any.""" return self.node_name + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self.attributes + @property def current_power_w(self): """Return the current power usage in W.""" @@ -67,3 +73,5 @@ class HiveDevicePlug(SwitchDevice): 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) diff --git a/homeassistant/components/switch/homekit_controller.py b/homeassistant/components/switch/homekit_controller.py new file mode 100644 index 00000000000..6b97200ba49 --- /dev/null +++ b/homeassistant/components/switch/homekit_controller.py @@ -0,0 +1,68 @@ +""" +Support for Homekit switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.homekit_controller/ +""" +import json +import logging + +from homeassistant.components.homekit_controller import (HomeKitEntity, + KNOWN_ACCESSORIES) +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = ['homekit_controller'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Homekit switch support.""" + if discovery_info is not None: + accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + add_devices([HomeKitSwitch(accessory, discovery_info)], True) + + +class HomeKitSwitch(HomeKitEntity, SwitchDevice): + """Representation of a Homekit switch.""" + + def __init__(self, *args): + """Initialise the switch.""" + super().__init__(*args) + self._on = None + + def update_characteristics(self, characteristics): + """Synchronise the switch state with Home Assistant.""" + # pylint: disable=import-error + import homekit + + for characteristic in characteristics: + ctype = characteristic['type'] + ctype = homekit.CharacteristicsTypes.get_short(ctype) + if ctype == "on": + self._chars['on'] = characteristic['iid'] + self._on = characteristic['value'] + elif ctype == "outlet-in-use": + self._chars['outlet-in-use'] = characteristic['iid'] + + @property + def is_on(self): + """Return true if device is on.""" + return self._on + + def turn_on(self, **kwargs): + """Turn the specified switch on.""" + self._on = True + characteristics = [{'aid': self._aid, + 'iid': self._chars['on'], + 'value': True}] + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) + + def turn_off(self, **kwargs): + """Turn the specified switch off.""" + characteristics = [{'aid': self._aid, + 'iid': self._chars['on'], + 'value': False}] + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) diff --git a/homeassistant/components/switch/homematicip_cloud.py b/homeassistant/components/switch/homematicip_cloud.py new file mode 100644 index 00000000000..9123d46c87b --- /dev/null +++ b/homeassistant/components/switch/homematicip_cloud.py @@ -0,0 +1,84 @@ +""" +Support for HomematicIP switch. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.homematicip_cloud/ +""" + +import logging + +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.homematicip_cloud import ( + HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, + ATTR_HOME_ID) + +DEPENDENCIES = ['homematicip_cloud'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_POWER_CONSUMPTION = 'power_consumption' +ATTR_ENERGIE_COUNTER = 'energie_counter' +ATTR_PROFILE_MODE = 'profile_mode' + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the HomematicIP switch devices.""" + from homematicip.device import ( + PlugableSwitch, PlugableSwitchMeasuring, + BrandSwitchMeasuring) + + 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, BrandSwitchMeasuring): + # BrandSwitchMeasuring inherits PlugableSwitchMeasuring + # This device is implemented in the light platform and will + # not be added in the switch platform + pass + elif isinstance(device, PlugableSwitchMeasuring): + devices.append(HomematicipSwitchMeasuring(home, device)) + elif isinstance(device, PlugableSwitch): + devices.append(HomematicipSwitch(home, device)) + + if devices: + async_add_devices(devices) + + +class HomematicipSwitch(HomematicipGenericDevice, SwitchDevice): + """MomematicIP switch device.""" + + def __init__(self, home, device): + """Initialize the switch device.""" + super().__init__(home, device) + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.on + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._device.turn_on() + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._device.turn_off() + + +class HomematicipSwitchMeasuring(HomematicipSwitch): + """MomematicIP measuring switch device.""" + + @property + def current_power_w(self): + """Return the current power usage in W.""" + return self._device.currentPowerConsumption + + @property + def today_energy_kwh(self): + """Return the today total energy usage in kWh.""" + if self._device.energyCounter is None: + return 0 + return round(self._device.energyCounter) diff --git a/homeassistant/components/switch/hydrawise.py b/homeassistant/components/switch/hydrawise.py new file mode 100644 index 00000000000..d0abe5febf5 --- /dev/null +++ b/homeassistant/components/switch/hydrawise.py @@ -0,0 +1,103 @@ +""" +Support for Hydrawise cloud. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.hydrawise/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.hydrawise import ( + ALLOWED_WATERING_TIME, CONF_WATERING_TIME, + DATA_HYDRAWISE, DEFAULT_WATERING_TIME, HydrawiseEntity, SWITCHES, + DEVICE_MAP, DEVICE_MAP_INDEX) +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import CONF_MONITORED_CONDITIONS + +DEPENDENCIES = ['hydrawise'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCHES): + vol.All(cv.ensure_list, [vol.In(SWITCHES)]), + vol.Optional(CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME): + vol.All(vol.In(ALLOWED_WATERING_TIME)), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for a Hydrawise device.""" + hydrawise = hass.data[DATA_HYDRAWISE].data + + default_watering_timer = config.get(CONF_WATERING_TIME) + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + # create a switch for each zone + for zone in hydrawise.relays: + sensors.append( + HydrawiseSwitch(default_watering_timer, + zone, + sensor_type)) + + add_devices(sensors, True) + + +class HydrawiseSwitch(HydrawiseEntity, SwitchDevice): + """A switch implementation for Hydrawise device.""" + + def __init__(self, default_watering_timer, *args): + """Initialize a switch for Hydrawise device.""" + super().__init__(*args) + self._default_watering_timer = default_watering_timer + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the device on.""" + if self._sensor_type == 'manual_watering': + self.hass.data[DATA_HYDRAWISE].data.run_zone( + self._default_watering_timer, (self.data['relay']-1)) + elif self._sensor_type == 'auto_watering': + self.hass.data[DATA_HYDRAWISE].data.suspend_zone( + 0, (self.data['relay']-1)) + + def turn_off(self, **kwargs): + """Turn the device off.""" + if self._sensor_type == 'manual_watering': + self.hass.data[DATA_HYDRAWISE].data.run_zone( + 0, (self.data['relay']-1)) + elif self._sensor_type == 'auto_watering': + self.hass.data[DATA_HYDRAWISE].data.suspend_zone( + 365, (self.data['relay']-1)) + + def update(self): + """Update device state.""" + mydata = self.hass.data[DATA_HYDRAWISE].data + _LOGGER.debug("Updating Hydrawise switch: %s", self._name) + if self._sensor_type == 'manual_watering': + if not mydata.running: + self._state = False + else: + self._state = int( + mydata.running[0]['relay']) == self.data['relay'] + elif self._sensor_type == 'auto_watering': + for relay in mydata.relays: + if relay['relay'] == self.data['relay']: + if relay.get('suspended') is not None: + self._state = False + else: + self._state = True + break + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return DEVICE_MAP[self._sensor_type][ + DEVICE_MAP_INDEX.index('ICON_INDEX')] diff --git a/homeassistant/components/switch/insteon_plm.py b/homeassistant/components/switch/insteon_plm.py index 5f9482ce955..be562e9d909 100644 --- a/homeassistant/components/switch/insteon_plm.py +++ b/homeassistant/components/switch/insteon_plm.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) @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] diff --git a/homeassistant/components/switch/konnected.py b/homeassistant/components/switch/konnected.py new file mode 100644 index 00000000000..53c6406b28a --- /dev/null +++ b/homeassistant/components/switch/konnected.py @@ -0,0 +1,94 @@ +""" +Support for wired switches attached to a Konnected device. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.konnected/ +""" + +import logging + +from homeassistant.components.konnected import ( + DOMAIN as KONNECTED_DOMAIN, PIN_TO_ZONE, CONF_ACTIVATION, + STATE_LOW, STATE_HIGH) +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.const import (CONF_DEVICES, CONF_SWITCHES, ATTR_STATE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['konnected'] + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set switches attached to a Konnected device.""" + if discovery_info is None: + return + + data = hass.data[KONNECTED_DOMAIN] + device_id = discovery_info['device_id'] + client = data[CONF_DEVICES][device_id]['client'] + switches = [KonnectedSwitch(device_id, pin_num, pin_data, client) + for pin_num, pin_data in + data[CONF_DEVICES][device_id][CONF_SWITCHES].items()] + async_add_devices(switches) + + +class KonnectedSwitch(ToggleEntity): + """Representation of a Konnected switch.""" + + def __init__(self, device_id, pin_num, data, client): + """Initialize the switch.""" + self._data = data + self._device_id = device_id + self._pin_num = pin_num + self._activation = self._data.get(CONF_ACTIVATION, STATE_HIGH) + self._state = self._boolean_state(self._data.get(ATTR_STATE)) + self._name = self._data.get( + 'name', 'Konnected {} Actuator {}'.format( + device_id, PIN_TO_ZONE[pin_num])) + self._client = client + _LOGGER.debug('Created new switch: %s', self._name) + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state + + def turn_on(self, **kwargs): + """Send a command to turn on the switch.""" + resp = self._client.put_device( + self._pin_num, int(self._activation == STATE_HIGH)) + + if resp.get(ATTR_STATE) is not None: + self._set_state(self._boolean_state(resp.get(ATTR_STATE))) + + def turn_off(self, **kwargs): + """Send a command to turn off the switch.""" + resp = self._client.put_device( + self._pin_num, int(self._activation == STATE_LOW)) + + if resp.get(ATTR_STATE) is not None: + self._set_state(self._boolean_state(resp.get(ATTR_STATE))) + + def _boolean_state(self, int_state): + if int_state is None: + return False + if int_state == 0: + return self._activation == STATE_LOW + if int_state == 1: + return self._activation == STATE_HIGH + + def _set_state(self, state): + self._state = state + self.schedule_update_ha_state() + _LOGGER.debug('Setting status of %s actuator pin %s to %s', + self._device_id, self.name, state) + + async def async_added_to_hass(self): + """Store entity_id.""" + self._data['entity_id'] = self.entity_id diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index a4aea1ded9f..1075888e199 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -4,8 +4,8 @@ Support for MQTT switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.mqtt/ """ -import asyncio import logging +from typing import Optional import voluptuous as vol @@ -17,9 +17,10 @@ from homeassistant.components.mqtt import ( from homeassistant.components.switch import SwitchDevice from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_OFF, - CONF_PAYLOAD_ON) + CONF_PAYLOAD_ON, CONF_ICON, STATE_ON) import homeassistant.components.mqtt as mqtt import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) @@ -29,17 +30,20 @@ DEFAULT_NAME = 'MQTT Switch' DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_OPTIMISTIC = False +CONF_UNIQUE_ID = 'unique_id' PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -@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 MQTT switch.""" if discovery_info is not None: config = PLATFORM_SCHEMA(discovery_info) @@ -50,6 +54,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): async_add_devices([MqttSwitch( config.get(CONF_NAME), + config.get(CONF_ICON), config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), config.get(CONF_AVAILABILITY_TOPIC), @@ -60,6 +65,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_OPTIMISTIC), config.get(CONF_PAYLOAD_AVAILABLE), config.get(CONF_PAYLOAD_NOT_AVAILABLE), + config.get(CONF_UNIQUE_ID), value_template, )]) @@ -67,14 +73,17 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttSwitch(MqttAvailability, SwitchDevice): """Representation of a switch that can be toggled using MQTT.""" - def __init__(self, name, state_topic, command_topic, availability_topic, + def __init__(self, name, icon, + state_topic, command_topic, availability_topic, qos, retain, payload_on, payload_off, optimistic, - payload_available, payload_not_available, value_template): + payload_available, payload_not_available, + unique_id: Optional[str], value_template): """Initialize the MQTT switch.""" super().__init__(availability_topic, qos, payload_available, payload_not_available) self._state = False self._name = name + self._icon = icon self._state_topic = state_topic self._command_topic = command_topic self._qos = qos @@ -83,11 +92,11 @@ class MqttSwitch(MqttAvailability, SwitchDevice): self._payload_off = payload_off self._optimistic = optimistic self._template = value_template + self._unique_id = unique_id - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe to MQTT events.""" - yield from super().async_added_to_hass() + await super().async_added_to_hass() @callback def state_message_received(topic, payload, qos): @@ -106,10 +115,16 @@ class MqttSwitch(MqttAvailability, SwitchDevice): # Force into optimistic mode. self._optimistic = True else: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._state_topic, state_message_received, self._qos) + if self._optimistic: + last_state = await async_get_last_state(self.hass, + self.entity_id) + if last_state: + self._state = last_state.state == STATE_ON + @property def should_poll(self): """Return the polling state.""" @@ -130,8 +145,17 @@ class MqttSwitch(MqttAvailability, SwitchDevice): """Return true if we do optimistic updates.""" return self._optimistic - @asyncio.coroutine - def async_turn_on(self, **kwargs): + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def icon(self): + """Return the icon.""" + return self._icon + + async def async_turn_on(self, **kwargs): """Turn the device on. This method is a coroutine. @@ -144,8 +168,7 @@ class MqttSwitch(MqttAvailability, SwitchDevice): self._state = True self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off. This method is a coroutine. diff --git a/homeassistant/components/switch/mysensors.py b/homeassistant/components/switch/mysensors.py index 51184859fc6..a91ca6d11e7 100644 --- a/homeassistant/components/switch/mysensors.py +++ b/homeassistant/components/switch/mysensors.py @@ -20,7 +20,8 @@ SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the mysensors platform for switches.""" device_class_map = { 'S_DOOR': MySensorsSwitch, @@ -39,9 +40,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): } mysensors.setup_mysensors_platform( hass, DOMAIN, discovery_info, device_class_map, - add_devices=add_devices) + async_add_devices=async_add_devices) - def send_ir_code_service(service): + async def async_send_ir_code_service(service): """Set IR code as device state attribute.""" entity_ids = service.data.get(ATTR_ENTITY_ID) ir_code = service.data.get(ATTR_IR_CODE) @@ -57,11 +58,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): kwargs = {ATTR_IR_CODE: ir_code} for device in _devices: - device.turn_on(**kwargs) + await device.async_turn_on(**kwargs) - hass.services.register(DOMAIN, SERVICE_SEND_IR_CODE, - send_ir_code_service, - schema=SEND_IR_CODE_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_SEND_IR_CODE, async_send_ir_code_service, + schema=SEND_IR_CODE_SERVICE_SCHEMA) class MySensorsSwitch(mysensors.MySensorsEntity, SwitchDevice): @@ -72,28 +73,34 @@ class MySensorsSwitch(mysensors.MySensorsEntity, SwitchDevice): """Return True if unable to access real state of entity.""" return self.gateway.optimistic + @property + def current_power_w(self): + """Return the current power usage in W.""" + set_req = self.gateway.const.SetReq + return self._values.get(set_req.V_WATT) + @property def is_on(self): """Return True if switch is on.""" return self._values.get(self.value_type) == STATE_ON - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the switch on.""" self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 1) if self.gateway.optimistic: # optimistically assume that switch has changed state self._values[self.value_type] = STATE_ON - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the switch off.""" self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 0) if self.gateway.optimistic: # optimistically assume that switch has changed state self._values[self.value_type] = STATE_OFF - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() class MySensorsIRSwitch(MySensorsSwitch): @@ -110,7 +117,7 @@ class MySensorsIRSwitch(MySensorsSwitch): set_req = self.gateway.const.SetReq return self._values.get(set_req.V_LIGHT) == STATE_ON - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the IR switch on.""" set_req = self.gateway.const.SetReq if ATTR_IR_CODE in kwargs: @@ -123,11 +130,11 @@ class MySensorsIRSwitch(MySensorsSwitch): # optimistically assume that switch has changed state self._values[self.value_type] = self._ir_code self._values[set_req.V_LIGHT] = STATE_ON - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() # turn off switch after switch was turned on - self.turn_off() + await self.async_turn_off() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the IR switch off.""" set_req = self.gateway.const.SetReq self.gateway.set_child_value( @@ -135,9 +142,9 @@ class MySensorsIRSwitch(MySensorsSwitch): if self.gateway.optimistic: # optimistically assume that switch has changed state self._values[set_req.V_LIGHT] = STATE_OFF - 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._ir_code = self._values.get(self.value_type) diff --git a/homeassistant/components/switch/mystrom.py b/homeassistant/components/switch/mystrom.py index e813da43dfa..0a87d41d2fe 100644 --- a/homeassistant/components/switch/mystrom.py +++ b/homeassistant/components/switch/mystrom.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_NAME, CONF_HOST) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-mystrom==0.3.8'] +REQUIREMENTS = ['python-mystrom==0.4.2'] DEFAULT_NAME = 'myStrom Switch' @@ -26,7 +26,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return myStrom switch.""" - from pymystrom import MyStromPlug, exceptions + from pymystrom.switch import MyStromPlug, exceptions name = config.get(CONF_NAME) host = config.get(CONF_HOST) @@ -45,7 +45,7 @@ class MyStromSwitch(SwitchDevice): def __init__(self, name, resource): """Initialize the myStrom switch.""" - from pymystrom import MyStromPlug + from pymystrom.switch import MyStromPlug self._name = name self._resource = resource diff --git a/homeassistant/components/switch/qwikswitch.py b/homeassistant/components/switch/qwikswitch.py index 7aea1dea1e1..193c2722534 100644 --- a/homeassistant/components/switch/qwikswitch.py +++ b/homeassistant/components/switch/qwikswitch.py @@ -4,21 +4,22 @@ Support for Qwikswitch relays. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.qwikswitch/ """ -import logging +from homeassistant.components.qwikswitch import ( + QSToggleEntity, DOMAIN as QWIKSWITCH) +from homeassistant.components.switch import SwitchDevice -import homeassistant.components.qwikswitch as qwikswitch - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['qwikswitch'] +DEPENDENCIES = [QWIKSWITCH] -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): - """Add switched from the main Qwikswitch component.""" +async def async_setup_platform(hass, _, add_devices, discovery_info=None): + """Add switches from the main Qwikswitch component.""" if discovery_info is None: - _LOGGER.error("Configure Qwikswitch component") - return False + return - add_devices(qwikswitch.QSUSB['switch']) - return True + qsusb = hass.data[QWIKSWITCH] + devs = [QSSwitch(qsid, qsusb) for qsid in discovery_info[QWIKSWITCH]] + add_devices(devs) + + +class QSSwitch(QSToggleEntity, SwitchDevice): + """Switch based on a Qwikswitch relay module.""" diff --git a/homeassistant/components/switch/raincloud.py b/homeassistant/components/switch/raincloud.py index 8a5c4347cf7..a4bac8fee1c 100644 --- a/homeassistant/components/switch/raincloud.py +++ b/homeassistant/components/switch/raincloud.py @@ -88,7 +88,6 @@ class RainCloudSwitch(RainCloudEntity, SwitchDevice): """Return the state attributes.""" return { ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - 'current_time': self.data.current_time, 'default_manual_timer': self._default_watering_timer, 'identifier': self.data.serial } diff --git a/homeassistant/components/switch/rainmachine.py b/homeassistant/components/switch/rainmachine.py index 99d41bdd9c3..bdee64a3d54 100644 --- a/homeassistant/components/switch/rainmachine.py +++ b/homeassistant/components/switch/rainmachine.py @@ -1,300 +1,314 @@ -"""Implements a RainMachine sprinkler controller for Home Assistant.""" +""" +This component provides support for RainMachine programs and zones. -from datetime import timedelta -from logging import getLogger +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.rainmachine/ +""" +import logging -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv +from homeassistant.components.rainmachine import ( + CONF_ZONE_RUN_TIME, DATA_RAINMACHINE, DEFAULT_ZONE_RUN, + PROGRAM_UPDATE_TOPIC, RainMachineEntity) +from homeassistant.const import ATTR_ID from homeassistant.components.switch import SwitchDevice -from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, CONF_EMAIL, CONF_IP_ADDRESS, - CONF_PASSWORD, CONF_PLATFORM, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SSL) -from homeassistant.util import Throttle +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) -_LOGGER = getLogger(__name__) -REQUIREMENTS = ['regenmaschine==0.4.1'] +DEPENDENCIES = ['rainmachine'] +_LOGGER = logging.getLogger(__name__) + +ATTR_AREA = 'area' +ATTR_CS_ON = 'cs_on' +ATTR_CURRENT_CYCLE = 'current_cycle' ATTR_CYCLES = 'cycles' -ATTR_TOTAL_DURATION = 'total_duration' +ATTR_DELAY = 'delay' +ATTR_DELAY_ON = 'delay_on' +ATTR_FIELD_CAPACITY = 'field_capacity' +ATTR_NO_CYCLES = 'number_of_cycles' +ATTR_PRECIP_RATE = 'sprinkler_head_precipitation_rate' +ATTR_RESTRICTIONS = 'restrictions' +ATTR_SLOPE = 'slope' +ATTR_SOAK = 'soak' +ATTR_SOIL_TYPE = 'soil_type' +ATTR_SPRINKLER_TYPE = 'sprinkler_head_type' +ATTR_STATUS = 'status' +ATTR_SUN_EXPOSURE = 'sun_exposure' +ATTR_VEGETATION_TYPE = 'vegetation_type' +ATTR_ZONES = 'zones' -CONF_ZONE_RUN_TIME = 'zone_run_time' +DAYS = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday' +] -DEFAULT_PORT = 8080 -DEFAULT_SSL = True -DEFAULT_ZONE_RUN_SECONDS = 60 * 10 +PROGRAM_STATUS_MAP = { + 0: 'Not Running', + 1: 'Running', + 2: 'Queued' +} -MIN_SCAN_TIME_LOCAL = timedelta(seconds=1) -MIN_SCAN_TIME_REMOTE = timedelta(seconds=5) -MIN_SCAN_TIME_FORCED = timedelta(milliseconds=100) +SOIL_TYPE_MAP = { + 0: 'Not Set', + 1: 'Clay Loam', + 2: 'Silty Clay', + 3: 'Clay', + 4: 'Loam', + 5: 'Sandy Loam', + 6: 'Loamy Sand', + 7: 'Sand', + 8: 'Sandy Clay', + 9: 'Silt Loam', + 10: 'Silt', + 99: 'Other' +} -PLATFORM_SCHEMA = vol.Schema( - vol.All( - cv.has_at_least_one_key(CONF_IP_ADDRESS, CONF_EMAIL), - { - vol.Required(CONF_PLATFORM): cv.string, - vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, - vol.Exclusive(CONF_IP_ADDRESS, 'auth'): cv.string, - vol.Exclusive(CONF_EMAIL, 'auth'): - vol.Email(), # pylint: disable=no-value-for-parameter - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, - vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN_SECONDS): - cv.positive_int - }), - extra=vol.ALLOW_EXTRA) +SLOPE_TYPE_MAP = { + 0: 'Not Set', + 1: 'Flat', + 2: 'Moderate', + 3: 'High', + 4: 'Very High', + 99: 'Other' +} + +SPRINKLER_TYPE_MAP = { + 0: 'Not Set', + 1: 'Popup Spray', + 2: 'Rotors', + 3: 'Surface Drip', + 4: 'Bubblers Drip', + 99: 'Other' +} + +SUN_EXPOSURE_MAP = { + 0: 'Not Set', + 1: 'Full Sun', + 2: 'Partial Shade', + 3: 'Full Shade' +} + +VEGETATION_MAP = { + 0: 'Not Set', + 2: 'Cool Season Grass', + 3: 'Fruit Trees', + 4: 'Flowers', + 5: 'Vegetables', + 6: 'Citrus', + 7: 'Trees and Bushes', + 9: 'Drought Tolerant Plants', + 10: 'Warm Season Grass', + 99: 'Other' +} def setup_platform(hass, config, add_devices, discovery_info=None): - """Set this component up under its platform.""" - import regenmaschine as rm + """Set up the RainMachine Switch platform.""" + if discovery_info is None: + return - _LOGGER.debug('Config data: %s', config) + _LOGGER.debug('Config received: %s', discovery_info) - ip_address = config.get(CONF_IP_ADDRESS, None) - email_address = config.get(CONF_EMAIL, None) - password = config[CONF_PASSWORD] - zone_run_time = config[CONF_ZONE_RUN_TIME] + zone_run_time = discovery_info.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN) - try: - if ip_address: - _LOGGER.debug('Configuring local API') + rainmachine = hass.data[DATA_RAINMACHINE] - port = config[CONF_PORT] - ssl = config[CONF_SSL] - auth = rm.Authenticator.create_local( - ip_address, password, port=port, https=ssl) - elif email_address: - _LOGGER.debug('Configuring remote API') - auth = rm.Authenticator.create_remote(email_address, password) + entities = [] + for program in rainmachine.client.programs.all().get('programs', {}): + if not program.get('active'): + continue - _LOGGER.debug('Querying against: %s', auth.url) + _LOGGER.debug('Adding program: %s', program) + entities.append(RainMachineProgram(rainmachine, program)) - client = rm.Client(auth) - device_name = client.provision.device_name()['name'] - device_mac = client.provision.wifi()['macAddress'] + for zone in rainmachine.client.zones.all().get('zones', {}): + if not zone.get('active'): + continue - entities = [] - for program in client.programs.all().get('programs', {}): - if not program.get('active'): - continue + _LOGGER.debug('Adding zone: %s', zone) + entities.append(RainMachineZone(rainmachine, zone, zone_run_time)) - _LOGGER.debug('Adding program: %s', program) - entities.append( - RainMachineProgram(client, device_name, device_mac, program)) - - for zone in client.zones.all().get('zones', {}): - if not zone.get('active'): - continue - - _LOGGER.debug('Adding zone: %s', zone) - entities.append( - RainMachineZone(client, device_name, device_mac, zone, - zone_run_time)) - - add_devices(entities) - except rm.exceptions.HTTPError as exc_info: - _LOGGER.error('An HTTP error occurred while talking with RainMachine') - _LOGGER.debug(exc_info) - return False - except UnboundLocalError as exc_info: - _LOGGER.error('Could not authenticate against RainMachine') - _LOGGER.debug(exc_info) - return False + add_devices(entities, True) -def aware_throttle(api_type): - """Create an API type-aware throttler.""" - _decorator = None - if api_type == 'local': +class RainMachineSwitch(RainMachineEntity, SwitchDevice): + """A class to represent a generic RainMachine switch.""" - @Throttle(MIN_SCAN_TIME_LOCAL, MIN_SCAN_TIME_FORCED) - def decorator(function): - """Create a local API throttler.""" - return function + def __init__(self, rainmachine, switch_type, obj): + """Initialize a generic RainMachine switch.""" + super().__init__(rainmachine) - _decorator = decorator - else: - - @Throttle(MIN_SCAN_TIME_REMOTE, MIN_SCAN_TIME_FORCED) - def decorator(function): - """Create a remote API throttler.""" - return function - - _decorator = decorator - - return _decorator - - -class RainMachineEntity(SwitchDevice): - """A class to represent a generic RainMachine entity.""" - - def __init__(self, client, device_name, device_mac, entity_json): - """Initialize a generic RainMachine entity.""" - self._api_type = 'remote' if client.auth.using_remote_api else 'local' - self._client = client - self._entity_json = entity_json - self.device_mac = device_mac - self.device_name = device_name - - self._attrs = { - ATTR_ATTRIBUTION: '© RainMachine', - ATTR_DEVICE_CLASS: self.device_name - } + self._name = obj['name'] + self._obj = obj + self._rainmachine_entity_id = obj['uid'] + self._switch_type = switch_type @property - def device_state_attributes(self) -> dict: - """Return the state attributes.""" - if self._client: - return self._attrs + def icon(self) -> str: + """Return the icon.""" + return 'mdi:water' @property def is_enabled(self) -> bool: """Return whether the entity is enabled.""" - return self._entity_json.get('active') - - @property - def rainmachine_entity_id(self) -> int: - """Return the RainMachine ID for this entity.""" - return self._entity_json.get('uid') - - @aware_throttle('local') - def _local_update(self) -> None: - """Call an update with scan times appropriate for the local API.""" - self._update() - - @aware_throttle('remote') - def _remote_update(self) -> None: - """Call an update with scan times appropriate for the remote API.""" - self._update() - - def _update(self) -> None: # pylint: disable=no-self-use - """Logic for update method, regardless of API type.""" - raise NotImplementedError() - - def update(self) -> None: - """Determine how the entity updates itself.""" - if self._api_type == 'remote': - self._remote_update() - else: - self._local_update() - - -class RainMachineProgram(RainMachineEntity): - """A RainMachine program.""" - - @property - def is_on(self) -> bool: - """Return whether the program is running.""" - return bool(self._entity_json.get('status')) - - @property - def name(self) -> str: - """Return the name of the program.""" - return 'Program: {}'.format(self._entity_json.get('name')) + return self._obj.get('active') @property def unique_id(self) -> str: """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_program_{1}'.format( - self.device_mac.replace(':', ''), self.rainmachine_entity_id) + return '{0}_{1}_{2}'.format( + self.rainmachine.device_mac.replace(':', ''), + self._switch_type, + self._rainmachine_entity_id) + + +class RainMachineProgram(RainMachineSwitch): + """A RainMachine program.""" + + def __init__(self, rainmachine, obj): + """Initialize a generic RainMachine switch.""" + super().__init__(rainmachine, 'program', obj) + + @property + def is_on(self) -> bool: + """Return whether the program is running.""" + return bool(self._obj.get('status')) + + @property + def zones(self) -> list: + """Return a list of active zones associated with this program.""" + return [z for z in self._obj['wateringTimes'] if z['active']] def turn_off(self, **kwargs) -> None: """Turn the program off.""" - import regenmaschine.exceptions as exceptions + from regenmaschine.exceptions import RainMachineError try: - self._client.programs.stop(self.rainmachine_entity_id) - except exceptions.BrokenAPICall: - _LOGGER.error('programs.stop currently broken in remote API') - except exceptions.HTTPError as exc_info: + self.rainmachine.client.programs.stop(self._rainmachine_entity_id) + dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC) + except RainMachineError as exc_info: _LOGGER.error('Unable to turn off program "%s"', self.unique_id) _LOGGER.debug(exc_info) def turn_on(self, **kwargs) -> None: """Turn the program on.""" - import regenmaschine.exceptions as exceptions + from regenmaschine.exceptions import RainMachineError try: - self._client.programs.start(self.rainmachine_entity_id) - except exceptions.BrokenAPICall: - _LOGGER.error('programs.start currently broken in remote API') - except exceptions.HTTPError as exc_info: + self.rainmachine.client.programs.start(self._rainmachine_entity_id) + dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC) + except RainMachineError as exc_info: _LOGGER.error('Unable to turn on program "%s"', self.unique_id) _LOGGER.debug(exc_info) - def _update(self) -> None: + def update(self) -> None: """Update info for the program.""" - import regenmaschine.exceptions as exceptions + from regenmaschine.exceptions import RainMachineError try: - self._entity_json = self._client.programs.get( - self.rainmachine_entity_id) - except exceptions.HTTPError as exc_info: + self._obj = self.rainmachine.client.programs.get( + self._rainmachine_entity_id) + + self._attrs.update({ + ATTR_ID: self._obj['uid'], + ATTR_SOAK: self._obj.get('soak'), + ATTR_STATUS: PROGRAM_STATUS_MAP[self._obj.get('status')], + ATTR_ZONES: ', '.join(z['name'] for z in self.zones) + }) + except RainMachineError as exc_info: _LOGGER.error('Unable to update info for program "%s"', self.unique_id) _LOGGER.debug(exc_info) -class RainMachineZone(RainMachineEntity): +class RainMachineZone(RainMachineSwitch): """A RainMachine zone.""" - def __init__(self, client, device_name, device_mac, zone_json, - zone_run_time): + def __init__(self, rainmachine, obj, zone_run_time): """Initialize a RainMachine zone.""" - super().__init__(client, device_name, device_mac, zone_json) + super().__init__(rainmachine, 'zone', obj) + + self._properties_json = {} self._run_time = zone_run_time - self._attrs.update({ - ATTR_CYCLES: self._entity_json.get('noOfCycles'), - ATTR_TOTAL_DURATION: self._entity_json.get('userDuration') - }) @property def is_on(self) -> bool: """Return whether the zone is running.""" - return bool(self._entity_json.get('state')) + return bool(self._obj.get('state')) - @property - def name(self) -> str: - """Return the name of the zone.""" - return 'Zone: {}'.format(self._entity_json.get('name')) + @callback + def _program_updated(self): + """Update state, trigger updates.""" + self.async_schedule_update_ha_state(True) - @property - def unique_id(self) -> str: - """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_zone_{1}'.format( - self.device_mac.replace(':', ''), self.rainmachine_entity_id) + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect(self.hass, PROGRAM_UPDATE_TOPIC, + self._program_updated) def turn_off(self, **kwargs) -> None: """Turn the zone off.""" - import regenmaschine.exceptions as exceptions + from regenmaschine.exceptions import RainMachineError try: - self._client.zones.stop(self.rainmachine_entity_id) - except exceptions.HTTPError as exc_info: + self.rainmachine.client.zones.stop(self._rainmachine_entity_id) + except RainMachineError as exc_info: _LOGGER.error('Unable to turn off zone "%s"', self.unique_id) _LOGGER.debug(exc_info) def turn_on(self, **kwargs) -> None: """Turn the zone on.""" - import regenmaschine.exceptions as exceptions + from regenmaschine.exceptions import RainMachineError try: - self._client.zones.start(self.rainmachine_entity_id, - self._run_time) - except exceptions.HTTPError as exc_info: + self.rainmachine.client.zones.start(self._rainmachine_entity_id, + self._run_time) + except RainMachineError as exc_info: _LOGGER.error('Unable to turn on zone "%s"', self.unique_id) _LOGGER.debug(exc_info) - def _update(self) -> None: + def update(self) -> None: """Update info for the zone.""" - import regenmaschine.exceptions as exceptions + from regenmaschine.exceptions import RainMachineError try: - self._entity_json = self._client.zones.get( - self.rainmachine_entity_id) - except exceptions.HTTPError as exc_info: + self._obj = self.rainmachine.client.zones.get( + self._rainmachine_entity_id) + + self._properties_json = self.rainmachine.client.zones.get( + self._rainmachine_entity_id, properties=True) + + self._attrs.update({ + ATTR_ID: self._obj['uid'], + ATTR_AREA: self._properties_json.get('waterSense').get('area'), + ATTR_CURRENT_CYCLE: self._obj.get('cycle'), + ATTR_FIELD_CAPACITY: + self._properties_json.get( + 'waterSense').get('fieldCapacity'), + ATTR_NO_CYCLES: self._obj.get('noOfCycles'), + ATTR_PRECIP_RATE: + self._properties_json.get( + 'waterSense').get('precipitationRate'), + ATTR_RESTRICTIONS: self._obj.get('restriction'), + ATTR_SLOPE: SLOPE_TYPE_MAP.get( + self._properties_json.get('slope')), + ATTR_SOIL_TYPE: + SOIL_TYPE_MAP.get(self._properties_json.get('sun')), + ATTR_SPRINKLER_TYPE: + SPRINKLER_TYPE_MAP.get( + self._properties_json.get('group_id')), + ATTR_SUN_EXPOSURE: + SUN_EXPOSURE_MAP.get(self._properties_json.get('sun')), + ATTR_VEGETATION_TYPE: + VEGETATION_MAP.get(self._obj.get('type')), + }) + except RainMachineError as exc_info: _LOGGER.error('Unable to update info for zone "%s"', self.unique_id) _LOGGER.debug(exc_info) diff --git a/homeassistant/components/switch/rfxtrx.py b/homeassistant/components/switch/rfxtrx.py index 7dd1d25ad94..68e91612008 100644 --- a/homeassistant/components/switch/rfxtrx.py +++ b/homeassistant/components/switch/rfxtrx.py @@ -10,11 +10,11 @@ import voluptuous as vol import homeassistant.components.rfxtrx as rfxtrx from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME from homeassistant.components.rfxtrx import ( CONF_AUTOMATIC_ADD, CONF_FIRE_EVENT, DEFAULT_SIGNAL_REPETITIONS, CONF_SIGNAL_REPETITIONS, CONF_DEVICES) from homeassistant.helpers import config_validation as cv +from homeassistant.const import CONF_NAME DEPENDENCIES = ['rfxtrx'] @@ -24,7 +24,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICES, default={}): { cv.string: vol.Schema({ vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, }) }, vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, diff --git a/homeassistant/components/switch/rpi_pfio.py b/homeassistant/components/switch/rpi_pfio.py index c10f417ba49..3031b1e0290 100644 --- a/homeassistant/components/switch/rpi_pfio.py +++ b/homeassistant/components/switch/rpi_pfio.py @@ -10,7 +10,7 @@ import voluptuous as vol import homeassistant.components.rpi_pfio as rpi_pfio from homeassistant.components.switch import PLATFORM_SCHEMA -from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.const import ATTR_NAME, DEVICE_DEFAULT_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity @@ -19,7 +19,6 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['rpi_pfio'] ATTR_INVERT_LOGIC = 'invert_logic' -ATTR_NAME = 'name' CONF_PORTS = 'ports' diff --git a/homeassistant/components/switch/services.yaml b/homeassistant/components/switch/services.yaml index f52b197d432..46b1237f57c 100644 --- a/homeassistant/components/switch/services.yaml +++ b/homeassistant/components/switch/services.yaml @@ -30,3 +30,34 @@ mysensors_send_ir_code: V_IR_SEND: description: IR code to send. example: '0xC284' + +xiaomi_miio_set_wifi_led_on: + description: Turn the wifi led on. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'switch.xiaomi_miio_device' +xiaomi_miio_set_wifi_led_off: + description: Turn the wifi led off. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'switch.xiaomi_miio_device' +xiaomi_miio_set_power_price: + description: Set the power price. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'switch.xiaomi_miio_device' + mode: + description: Power price, between 0 and 999. + example: 31 +xiaomi_miio_set_power_mode: + description: Set the power mode. + fields: + entity_id: + description: Name of the xiaomi miio entity. + example: 'switch.xiaomi_miio_device' + mode: + description: Power mode, valid values are 'normal' and 'green'. + example: 'green' diff --git a/homeassistant/components/switch/tahoma.py b/homeassistant/components/switch/tahoma.py new file mode 100644 index 00000000000..aa3554a494c --- /dev/null +++ b/homeassistant/components/switch/tahoma.py @@ -0,0 +1,51 @@ +""" +Support for Tahoma Switch - those are push buttons for garage door etc. + +Those buttons are implemented as switches that are never on. They only +receive the turn_on action, perform the relay click, and stay in OFF state + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.tahoma/ +""" +import logging + +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.tahoma import ( + DOMAIN as TAHOMA_DOMAIN, TahomaDevice) + +DEPENDENCIES = ['tahoma'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tahoma switches.""" + controller = hass.data[TAHOMA_DOMAIN]['controller'] + devices = [] + for switch in hass.data[TAHOMA_DOMAIN]['devices']['switch']: + devices.append(TahomaSwitch(switch, controller)) + add_devices(devices, True) + + +class TahomaSwitch(TahomaDevice, SwitchDevice): + """Representation a Tahoma Switch.""" + + @property + def device_class(self): + """Return the class of the device.""" + if self.tahoma_device.type == 'rts:GarageDoor4TRTSComponent': + return 'garage' + return None + + def turn_on(self, **kwargs): + """Send the on command.""" + self.toggle() + + def toggle(self, **kwargs): + """Click the switch.""" + self.apply_action('cycle') + + @property + def is_on(self): + """Get whether the switch is in on state.""" + return False diff --git a/homeassistant/components/switch/telnet.py b/homeassistant/components/switch/telnet.py index 7c69b31aa00..c3a608b9692 100644 --- a/homeassistant/components/switch/telnet.py +++ b/homeassistant/components/switch/telnet.py @@ -25,7 +25,7 @@ SWITCH_SCHEMA = vol.Schema({ vol.Required(CONF_COMMAND_OFF): cv.string, vol.Required(CONF_COMMAND_ON): cv.string, vol.Required(CONF_RESOURCE): cv.string, - vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_COMMAND_STATE): cv.string, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, diff --git a/homeassistant/components/switch/vesync.py b/homeassistant/components/switch/vesync.py new file mode 100644 index 00000000000..d8579a508e2 --- /dev/null +++ b/homeassistant/components/switch/vesync.py @@ -0,0 +1,108 @@ +""" +Support for Etekcity VeSync switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.vesync/ +""" +import logging +import voluptuous as vol +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) +import homeassistant.helpers.config_validation as cv + + +REQUIREMENTS = ['pyvesync==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the VeSync switch platform.""" + from pyvesync.vesync import VeSync + + switches = [] + + manager = VeSync(config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) + + if not manager.login(): + _LOGGER.error("Unable to login to VeSync") + return + + manager.update() + + if manager.devices is not None and manager.devices: + if len(manager.devices) == 1: + count_string = 'switch' + else: + count_string = 'switches' + + _LOGGER.info("Discovered %d VeSync %s", + len(manager.devices), count_string) + + for switch in manager.devices: + switches.append(VeSyncSwitchHA(switch)) + _LOGGER.info("Added a VeSync switch named '%s'", + switch.device_name) + else: + _LOGGER.info("No VeSync devices found") + + add_devices(switches) + + +class VeSyncSwitchHA(SwitchDevice): + """Representation of a VeSync switch.""" + + def __init__(self, plug): + """Initialize the VeSync switch device.""" + self.smartplug = plug + self._current_power_w = None + self._today_energy_kwh = None + + @property + def unique_id(self): + """Return the ID of this switch.""" + return self.smartplug.cid + + @property + def name(self): + """Return the name of the switch.""" + return self.smartplug.device_name + + @property + def current_power_w(self): + """Return the current power usage in W.""" + return self._current_power_w + + @property + def today_energy_kwh(self): + """Return the today total energy usage in kWh.""" + return self._today_energy_kwh + + @property + def available(self) -> bool: + """Return True if switch is available.""" + return self.smartplug.connection_status == "online" + + @property + def is_on(self): + """Return True if switch is on.""" + return self.smartplug.device_status == "on" + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self.smartplug.turn_on() + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self.smartplug.turn_off() + + def update(self): + """Handle data changes for node values.""" + self.smartplug.update() + self._current_power_w = self.smartplug.get_power() + self._today_energy_kwh = self.smartplug.get_kwh_today() diff --git a/homeassistant/components/switch/xiaomi_aqara.py b/homeassistant/components/switch/xiaomi_aqara.py index 939fc70660a..4c44d6b2592 100644 --- a/homeassistant/components/switch/xiaomi_aqara.py +++ b/homeassistant/components/switch/xiaomi_aqara.py @@ -26,7 +26,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for device in gateway.devices['switch']: model = device['model'] if model == 'plug': - devices.append(XiaomiGenericSwitch(device, "Plug", 'status', + if 'proto' not in device or int(device['proto'][0:1]) == 1: + data_key = 'status' + else: + data_key = 'channel_0' + devices.append(XiaomiGenericSwitch(device, "Plug", data_key, True, gateway)) elif model in ['ctrl_neutral1', 'ctrl_neutral1.aq1']: devices.append(XiaomiGenericSwitch(device, 'Wall Switch', @@ -52,7 +56,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 'Wall Switch LN Right', 'channel_1', False, gateway)) - elif model in ['86plug', 'ctrl_86plug.aq1']: + elif model in ['86plug', 'ctrl_86plug', 'ctrl_86plug.aq1']: devices.append(XiaomiGenericSwitch(device, 'Wall Plug', 'status', True, gateway)) add_devices(devices) diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 1a8feb5811d..149acd76c07 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -11,15 +11,20 @@ import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA, ) -from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ) +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA, + DOMAIN, ) +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, + ATTR_ENTITY_ID, ) from homeassistant.exceptions import PlatformNotReady _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Miio Switch' +DATA_KEY = 'switch.xiaomi_miio' CONF_MODEL = 'model' +MODEL_POWER_STRIP_V2 = 'zimi.powerstrip.v2' +MODEL_PLUG_V3 = 'chuangmi.plug.v3' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -30,23 +35,75 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'qmi.powerstrip.v1', 'zimi.powerstrip.v2', 'chuangmi.plug.m1', - 'chuangmi.plug.v2']), + 'chuangmi.plug.v2', + 'chuangmi.plug.v3']), }) -REQUIREMENTS = ['python-miio==0.3.8'] +REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] ATTR_POWER = 'power' ATTR_TEMPERATURE = 'temperature' ATTR_LOAD_POWER = 'load_power' ATTR_MODEL = 'model' +ATTR_MODE = 'mode' +ATTR_POWER_MODE = 'power_mode' +ATTR_WIFI_LED = 'wifi_led' +ATTR_POWER_PRICE = 'power_price' +ATTR_PRICE = 'price' + SUCCESS = ['ok'] +FEATURE_SET_POWER_MODE = 1 +FEATURE_SET_WIFI_LED = 2 +FEATURE_SET_POWER_PRICE = 4 + +FEATURE_FLAGS_GENERIC = 0 + +FEATURE_FLAGS_POWER_STRIP_V1 = (FEATURE_SET_POWER_MODE | + FEATURE_SET_WIFI_LED | + FEATURE_SET_POWER_PRICE) + +FEATURE_FLAGS_POWER_STRIP_V2 = (FEATURE_SET_WIFI_LED | + FEATURE_SET_POWER_PRICE) + +FEATURE_FLAGS_PLUG_V3 = (FEATURE_SET_WIFI_LED) + +SERVICE_SET_WIFI_LED_ON = 'xiaomi_miio_set_wifi_led_on' +SERVICE_SET_WIFI_LED_OFF = 'xiaomi_miio_set_wifi_led_off' +SERVICE_SET_POWER_MODE = 'xiaomi_miio_set_power_mode' +SERVICE_SET_POWER_PRICE = 'xiaomi_miio_set_power_price' + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +SERVICE_SCHEMA_POWER_MODE = SERVICE_SCHEMA.extend({ + vol.Required(ATTR_MODE): vol.All(vol.In(['green', 'normal'])), +}) + +SERVICE_SCHEMA_POWER_PRICE = SERVICE_SCHEMA.extend({ + vol.Required(ATTR_PRICE): vol.All(vol.Coerce(float), vol.Range(min=0)) +}) + +SERVICE_TO_METHOD = { + SERVICE_SET_WIFI_LED_ON: {'method': 'async_set_wifi_led_on'}, + SERVICE_SET_WIFI_LED_OFF: {'method': 'async_set_wifi_led_off'}, + SERVICE_SET_POWER_MODE: { + 'method': 'async_set_power_mode', + 'schema': SERVICE_SCHEMA_POWER_MODE}, + SERVICE_SET_POWER_PRICE: { + 'method': 'async_set_power_price', + 'schema': SERVICE_SCHEMA_POWER_PRICE}, +} + # pylint: disable=unused-argument -@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 switch from config.""" from miio import Device, DeviceException + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} host = config.get(CONF_HOST) name = config.get(CONF_NAME) @@ -56,12 +113,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) devices = [] + unique_id = None if model is None: try: miio_device = Device(host, token) device_info = miio_device.info() model = device_info.model + unique_id = "{}-{}".format(model, device_info.mac_address) _LOGGER.info("%s %s %s detected", model, device_info.firmware_version, @@ -69,29 +128,30 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): except DeviceException: raise PlatformNotReady - if model in ['chuangmi.plug.v1']: - from miio import PlugV1 - plug = PlugV1(host, token) + if model in ['chuangmi.plug.v1', 'chuangmi.plug.v3']: + from miio import ChuangmiPlug + plug = ChuangmiPlug(host, token, model=model) # The device has two switchable channels (mains and a USB port). # A switch device per channel will be created. for channel_usb in [True, False]: - device = ChuangMiPlugV1Switch( - name, plug, model, channel_usb) + device = ChuangMiPlugSwitch( + name, plug, model, unique_id, channel_usb) devices.append(device) + hass.data[DATA_KEY][host] = device - elif model in ['qmi.powerstrip.v1', - 'zimi.powerstrip.v2']: + elif model in ['qmi.powerstrip.v1', 'zimi.powerstrip.v2']: from miio import PowerStrip plug = PowerStrip(host, token) - device = XiaomiPowerStripSwitch(name, plug, model) + device = XiaomiPowerStripSwitch(name, plug, model, unique_id) devices.append(device) - elif model in ['chuangmi.plug.m1', - 'chuangmi.plug.v2']: - from miio import Plug - plug = Plug(host, token) - device = XiaomiPlugGenericSwitch(name, plug, model) + hass.data[DATA_KEY][host] = device + elif model in ['chuangmi.plug.m1', 'chuangmi.plug.v2']: + from miio import ChuangmiPlug + plug = ChuangmiPlug(host, token, model=model) + device = XiaomiPlugGenericSwitch(name, plug, model, unique_id) devices.append(device) + hass.data[DATA_KEY][host] = device else: _LOGGER.error( 'Unsupported device found! Please create an issue at ' @@ -101,22 +161,52 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): async_add_devices(devices, update_before_add=True) + async def async_service_handler(service): + """Map services to methods on XiaomiPlugGenericSwitch.""" + method = SERVICE_TO_METHOD.get(service.service) + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + devices = [device for device in hass.data[DATA_KEY].values() if + device.entity_id in entity_ids] + else: + devices = hass.data[DATA_KEY].values() + + update_tasks = [] + for device in devices: + if not hasattr(device, method['method']): + continue + await getattr(device, method['method'])(**params) + update_tasks.append(device.async_update_ha_state(True)) + + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) + + for plug_service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[plug_service].get('schema', SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, plug_service, async_service_handler, schema=schema) + class XiaomiPlugGenericSwitch(SwitchDevice): """Representation of a Xiaomi Plug Generic.""" - def __init__(self, name, plug, model): + def __init__(self, name, plug, model, unique_id): """Initialize the plug switch.""" self._name = name - self._icon = 'mdi:power-socket' - self._model = model - self._plug = plug + self._model = model + self._unique_id = unique_id + + self._icon = 'mdi:power-socket' + self._available = False self._state = None self._state_attrs = { ATTR_TEMPERATURE: None, ATTR_MODEL: self._model, } + self._device_features = FEATURE_FLAGS_GENERIC self._skip_update = False @property @@ -124,6 +214,11 @@ class XiaomiPlugGenericSwitch(SwitchDevice): """Poll the plug.""" return True + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + @property def name(self): """Return the name of the device if any.""" @@ -137,7 +232,7 @@ class XiaomiPlugGenericSwitch(SwitchDevice): @property def available(self): """Return true when state is known.""" - return self._state is not None + return self._available @property def device_state_attributes(self): @@ -149,43 +244,44 @@ class XiaomiPlugGenericSwitch(SwitchDevice): """Return true if switch is on.""" return self._state - @asyncio.coroutine - def _try_command(self, mask_error, func, *args, **kwargs): + async def _try_command(self, mask_error, func, *args, **kwargs): """Call a plug command handling error messages.""" from miio import DeviceException try: - result = yield from self.hass.async_add_job( + result = await self.hass.async_add_job( partial(func, *args, **kwargs)) _LOGGER.debug("Response received from plug: %s", result) + # The Chuangmi Plug V3 returns 0 on success on usb_on/usb_off. + if func in ['usb_on', 'usb_off'] and result == 0: + return True + return result == SUCCESS except DeviceException as exc: _LOGGER.error(mask_error, exc) + self._available = False return False - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the plug on.""" - result = yield from self._try_command( + result = await self._try_command( "Turning the plug on failed.", self._plug.on) if result: self._state = True self._skip_update = True - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the plug off.""" - result = yield from self._try_command( + result = await self._try_command( "Turning the plug off failed.", self._plug.off) if result: self._state = False self._skip_update = True - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Fetch state from the device.""" from miio import DeviceException @@ -195,34 +291,73 @@ class XiaomiPlugGenericSwitch(SwitchDevice): return try: - state = yield from self.hass.async_add_job(self._plug.status) + state = await self.hass.async_add_job(self._plug.status) _LOGGER.debug("Got new state: %s", state) + self._available = True self._state = state.is_on self._state_attrs.update({ ATTR_TEMPERATURE: state.temperature }) except DeviceException as ex: - self._state = None + self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) + async def async_set_wifi_led_on(self): + """Turn the wifi led on.""" + if self._device_features & FEATURE_SET_WIFI_LED == 0: + return -class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch, SwitchDevice): + await self._try_command( + "Turning the wifi led on failed.", + self._plug.set_wifi_led, True) + + async def async_set_wifi_led_off(self): + """Turn the wifi led on.""" + if self._device_features & FEATURE_SET_WIFI_LED == 0: + return + + await self._try_command( + "Turning the wifi led off failed.", + self._plug.set_wifi_led, False) + + async def async_set_power_price(self, price: int): + """Set the power price.""" + if self._device_features & FEATURE_SET_POWER_PRICE == 0: + return + + await self._try_command( + "Setting the power price of the power strip failed.", + self._plug.set_power_price, price) + + +class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): """Representation of a Xiaomi Power Strip.""" - def __init__(self, name, plug, model): + def __init__(self, name, plug, model, unique_id): """Initialize the plug switch.""" - XiaomiPlugGenericSwitch.__init__(self, name, plug, model) + super().__init__(name, plug, model, unique_id) - self._state_attrs = { - ATTR_TEMPERATURE: None, + if self._model == MODEL_POWER_STRIP_V2: + self._device_features = FEATURE_FLAGS_POWER_STRIP_V2 + else: + self._device_features = FEATURE_FLAGS_POWER_STRIP_V1 + + self._state_attrs.update({ ATTR_LOAD_POWER: None, - ATTR_MODEL: self._model, - } + }) - @asyncio.coroutine - def async_update(self): + if self._device_features & FEATURE_SET_POWER_MODE == 1: + self._state_attrs[ATTR_POWER_MODE] = None + + if self._device_features & FEATURE_SET_WIFI_LED == 1: + self._state_attrs[ATTR_WIFI_LED] = None + + if self._device_features & FEATURE_SET_POWER_PRICE == 1: + self._state_attrs[ATTR_POWER_PRICE] = None + + async def async_update(self): """Fetch state from the device.""" from miio import DeviceException @@ -232,60 +367,91 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch, SwitchDevice): return try: - state = yield from self.hass.async_add_job(self._plug.status) + state = await self.hass.async_add_job(self._plug.status) _LOGGER.debug("Got new state: %s", state) + self._available = True self._state = state.is_on self._state_attrs.update({ ATTR_TEMPERATURE: state.temperature, - ATTR_LOAD_POWER: state.load_power + ATTR_LOAD_POWER: state.load_power, }) + if self._device_features & FEATURE_SET_POWER_MODE == 1 and \ + state.mode: + self._state_attrs[ATTR_POWER_MODE] = state.mode.value + + if self._device_features & FEATURE_SET_WIFI_LED == 1 and \ + state.wifi_led: + self._state_attrs[ATTR_WIFI_LED] = state.wifi_led + + if self._device_features & FEATURE_SET_POWER_PRICE == 1 and \ + state.power_price: + self._state_attrs[ATTR_POWER_PRICE] = state.power_price + except DeviceException as ex: - self._state = None + self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) + async def async_set_power_mode(self, mode: str): + """Set the power mode.""" + if self._device_features & FEATURE_SET_POWER_MODE == 0: + return -class ChuangMiPlugV1Switch(XiaomiPlugGenericSwitch, SwitchDevice): - """Representation of a Chuang Mi Plug V1.""" + from miio.powerstrip import PowerMode - def __init__(self, name, plug, model, channel_usb): + await self._try_command( + "Setting the power mode of the power strip failed.", + self._plug.set_power_mode, PowerMode(mode)) + + +class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): + """Representation of a Chuang Mi Plug V1 and V3.""" + + def __init__(self, name, plug, model, unique_id, channel_usb): """Initialize the plug switch.""" name = '{} USB'.format(name) if channel_usb else name - XiaomiPlugGenericSwitch.__init__(self, name, plug, model) + if unique_id is not None and channel_usb: + unique_id = "{}-{}".format(unique_id, 'usb') + + super().__init__(name, plug, model, unique_id) self._channel_usb = channel_usb - @asyncio.coroutine - def async_turn_on(self, **kwargs): + if self._model == MODEL_PLUG_V3: + self._device_features = FEATURE_FLAGS_PLUG_V3 + self._state_attrs.update({ + ATTR_WIFI_LED: None, + ATTR_LOAD_POWER: None, + }) + + async def async_turn_on(self, **kwargs): """Turn a channel on.""" if self._channel_usb: - result = yield from self._try_command( + result = await self._try_command( "Turning the plug on failed.", self._plug.usb_on) else: - result = yield from self._try_command( + result = await self._try_command( "Turning the plug on failed.", self._plug.on) if result: self._state = True self._skip_update = True - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn a channel off.""" if self._channel_usb: - result = yield from self._try_command( + result = await self._try_command( "Turning the plug on failed.", self._plug.usb_off) else: - result = yield from self._try_command( + result = await self._try_command( "Turning the plug on failed.", self._plug.off) if result: self._state = False self._skip_update = True - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Fetch state from the device.""" from miio import DeviceException @@ -295,9 +461,10 @@ class ChuangMiPlugV1Switch(XiaomiPlugGenericSwitch, SwitchDevice): return try: - state = yield from self.hass.async_add_job(self._plug.status) + state = await self.hass.async_add_job(self._plug.status) _LOGGER.debug("Got new state: %s", state) + self._available = True if self._channel_usb: self._state = state.usb_power else: @@ -307,6 +474,12 @@ class ChuangMiPlugV1Switch(XiaomiPlugGenericSwitch, SwitchDevice): ATTR_TEMPERATURE: state.temperature }) + if state.wifi_led: + self._state_attrs[ATTR_WIFI_LED] = state.wifi_led + + if state.load_power: + self._state_attrs[ATTR_LOAD_POWER] = state.load_power + except DeviceException as ex: - self._state = None + self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index 7de9f1459b1..6109dc192f3 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -51,18 +51,30 @@ class Switch(zha.Entity, SwitchDevice): @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" - if self._state == 'unknown': + if self._state is None: return False return bool(self._state) async def async_turn_on(self, **kwargs): """Turn the entity on.""" - await self._endpoint.on_off.on() + from zigpy.exceptions import DeliveryError + try: + await self._endpoint.on_off.on() + except DeliveryError as ex: + _LOGGER.error("Unable to turn the switch on: %s", ex) + return + self._state = 1 async def async_turn_off(self, **kwargs): """Turn the entity off.""" - await self._endpoint.on_off.off() + from zigpy.exceptions import DeliveryError + try: + await self._endpoint.on_off.off() + except DeliveryError as ex: + _LOGGER.error("Unable to turn the switch off: %s", ex) + return + self._state = 0 async def async_update(self): diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 1dad1f3a1eb..2a2a19aa2f5 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -19,12 +19,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP CONF_MAX_ENTRIES = 'max_entries' +CONF_FIRE_EVENT = 'fire_event' CONF_MESSAGE = 'message' CONF_LEVEL = 'level' CONF_LOGGER = 'logger' DATA_SYSTEM_LOG = 'system_log' DEFAULT_MAX_ENTRIES = 50 +DEFAULT_FIRE_EVENT = False DEPENDENCIES = ['http'] DOMAIN = 'system_log' @@ -37,6 +39,7 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES): cv.positive_int, + vol.Optional(CONF_FIRE_EVENT, default=DEFAULT_FIRE_EVENT): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) @@ -97,11 +100,12 @@ def _exception_as_string(exc_info): class LogErrorHandler(logging.Handler): """Log handler for error messages.""" - def __init__(self, hass, maxlen): + def __init__(self, hass, maxlen, fire_event): """Initialize a new LogErrorHandler.""" super().__init__() self.hass = hass self.records = deque(maxlen=maxlen) + self.fire_event = fire_event def _create_entry(self, record, call_stack): return { @@ -122,15 +126,12 @@ class LogErrorHandler(logging.Handler): if record.levelno >= logging.WARN: stack = [] if not record.exc_info: - try: - stack = [f for f, _, _, _ in traceback.extract_stack()] - except ValueError: - # On Python 3.4 under py.test getting the stack might fail. - pass + stack = [f for f, _, _, _ in traceback.extract_stack()] entry = self._create_entry(record, stack) self.records.appendleft(entry) - self.hass.bus.fire(EVENT_SYSTEM_LOG, entry) + if self.fire_event: + self.hass.bus.fire(EVENT_SYSTEM_LOG, entry) @asyncio.coroutine @@ -140,7 +141,8 @@ def async_setup(hass, config): if conf is None: conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] - handler = LogErrorHandler(hass, conf.get(CONF_MAX_ENTRIES)) + handler = LogErrorHandler(hass, conf[CONF_MAX_ENTRIES], + conf[CONF_FIRE_EVENT]) logging.getLogger().addHandler(handler) hass.http.register_view(AllErrorsView(handler)) diff --git a/homeassistant/components/tado.py b/homeassistant/components/tado.py index cfba0a5c0c4..7c045518132 100644 --- a/homeassistant/components/tado.py +++ b/homeassistant/components/tado.py @@ -15,7 +15,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.util import Throttle -REQUIREMENTS = ['python-tado==0.2.2'] +REQUIREMENTS = ['python-tado==0.2.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 7c8d047fbcf..84edd9afd40 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -32,17 +32,22 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) TAHOMA_COMPONENTS = [ - 'scene', 'sensor', 'cover' + 'scene', 'sensor', 'cover', 'switch' ] TAHOMA_TYPES = { 'rts:RollerShutterRTSComponent': 'cover', 'rts:CurtainRTSComponent': 'cover', + 'rts:BlindRTSComponent': 'cover', + 'rts:VenetianBlindRTSComponent': 'cover', + 'io:ExteriorVenetianBlindIOComponent': 'cover', + 'io:RollerShutterUnoIOComponent': 'cover', 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', 'io:RollerShutterVeluxIOComponent': 'cover', 'io:RollerShutterGenericIOComponent': 'cover', 'io:WindowOpenerVeluxIOComponent': 'cover', 'io:LightIOSystemSensor': 'sensor', + 'rts:GarageDoor4TRTSComponent': 'switch', } diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 9e5d4cd9665..b9329a46b72 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -22,7 +22,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['python-telegram-bot==9.0.0'] +REQUIREMENTS = ['python-telegram-bot==10.1.0'] _LOGGER = logging.getLogger(__name__) @@ -63,6 +63,7 @@ DOMAIN = 'telegram_bot' SERVICE_SEND_MESSAGE = 'send_message' SERVICE_SEND_PHOTO = 'send_photo' +SERVICE_SEND_STICKER = 'send_sticker' SERVICE_SEND_VIDEO = 'send_video' SERVICE_SEND_DOCUMENT = 'send_document' SERVICE_SEND_LOCATION = 'send_location' @@ -154,6 +155,7 @@ SERVICE_SCHEMA_DELETE_MESSAGE = vol.Schema({ SERVICE_MAP = { SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, + SERVICE_SEND_STICKER: SERVICE_SCHEMA_SEND_FILE, SERVICE_SEND_VIDEO: SERVICE_SCHEMA_SEND_FILE, SERVICE_SEND_DOCUMENT: SERVICE_SCHEMA_SEND_FILE, SERVICE_SEND_LOCATION: SERVICE_SCHEMA_SEND_LOCATION, @@ -167,10 +169,10 @@ SERVICE_MAP = { def load_data(hass, url=None, filepath=None, username=None, password=None, authentication=None, num_retries=5): - """Load photo/document into ByteIO/File container from a source.""" + """Load data into ByteIO/File container from a source.""" try: if url is not None: - # Load photo from URL + # Load data from URL params = {"timeout": 15} if username is not None and password is not None: if authentication == HTTP_DIGEST_AUTHENTICATION: @@ -181,7 +183,7 @@ def load_data(hass, url=None, filepath=None, username=None, password=None, while retry_num < num_retries: req = requests.get(url, **params) if not req.ok: - _LOGGER.warning("Status code %s (retry #%s) loading %s.", + _LOGGER.warning("Status code %s (retry #%s) loading %s", req.status_code, retry_num + 1, url) else: data = io.BytesIO(req.content) @@ -189,10 +191,10 @@ def load_data(hass, url=None, filepath=None, username=None, password=None, data.seek(0) data.name = url return data - _LOGGER.warning("Empty data (retry #%s) in %s).", + _LOGGER.warning("Empty data (retry #%s) in %s)", retry_num + 1, url) retry_num += 1 - _LOGGER.warning("Can't load photo in %s after %s retries.", + _LOGGER.warning("Can't load data in %s after %s retries", url, retry_num) elif filepath is not None: if hass.config.is_allowed_path(filepath): @@ -200,10 +202,10 @@ def load_data(hass, url=None, filepath=None, username=None, password=None, _LOGGER.warning("'%s' are not secure to load data from!", filepath) else: - _LOGGER.warning("Can't load photo. No photo found in params!") + _LOGGER.warning("Can't load data. No data found in params!") except (OSError, TypeError) as error: - _LOGGER.error("Can't load photo into ByteIO: %s", error) + _LOGGER.error("Can't load data into ByteIO: %s", error) return None @@ -274,9 +276,8 @@ def async_setup(hass, config): if msgtype == SERVICE_SEND_MESSAGE: yield from hass.async_add_job( partial(notify_service.send_message, **kwargs)) - elif (msgtype == SERVICE_SEND_PHOTO or - msgtype == SERVICE_SEND_VIDEO or - msgtype == SERVICE_SEND_DOCUMENT): + elif msgtype in [SERVICE_SEND_PHOTO, SERVICE_SEND_STICKER, + SERVICE_SEND_VIDEO, SERVICE_SEND_DOCUMENT]: yield from hass.async_add_job( partial(notify_service.send_file, msgtype, **kwargs)) elif msgtype == SERVICE_SEND_LOCATION: @@ -524,11 +525,12 @@ class TelegramNotificationService: text=message, show_alert=show_alert, **params) def send_file(self, file_type=SERVICE_SEND_PHOTO, target=None, **kwargs): - """Send a photo, video, or document.""" + """Send a photo, sticker, video, or document.""" params = self._get_msg_kwargs(kwargs) caption = kwargs.get(ATTR_CAPTION) func_send = { SERVICE_SEND_PHOTO: self.bot.sendPhoto, + SERVICE_SEND_STICKER: self.bot.sendSticker, SERVICE_SEND_VIDEO: self.bot.sendVideo, SERVICE_SEND_DOCUMENT: self.bot.sendDocument }.get(file_type) diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 4c144fe42db..d8039c0b384 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -59,6 +59,34 @@ send_photo: description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' +send_sticker: + description: Send a sticker. + fields: + url: + description: Remote path to an webp sticker. + example: 'http://example.org/path/to/the/sticker.webp' + file: + description: Local path to an webp sticker. + example: '/path/to/the/sticker.webp' + username: + description: Username for a URL which require HTTP basic authentication. + example: myuser + password: + description: Password for a URL which require HTTP basic authentication. + example: myuser_pwd + target: + description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. + example: '[12345, 67890] or 12345' + disable_notification: + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + example: true + keyboard: + description: List of rows of commands, comma-separated, to make a custom keyboard. + example: '["/command1, /command2", "/command3"]' + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + send_video: description: Send a video. fields: diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 84d2d3f349d..5a363e84d7b 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -122,8 +122,7 @@ def async_finish(hass, entity_id): DOMAIN, SERVICE_FINISH, {ATTR_ENTITY_ID: entity_id})) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up a timer.""" component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -142,8 +141,7 @@ def async_setup(hass, config): if not entities: return False - @asyncio.coroutine - def async_handler_service(service): + async def async_handler_service(service): """Handle a call to the timer services.""" target_timers = component.async_extract_from_service(service) @@ -162,7 +160,7 @@ def async_setup(hass, config): timer.async_start(service.data.get(ATTR_DURATION)) ) if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_START, async_handler_service, @@ -177,7 +175,7 @@ def async_setup(hass, config): DOMAIN, SERVICE_FINISH, async_handler_service, schema=SERVICE_SCHEMA) - yield from component.async_add_entities(entities) + await component.async_add_entities(entities) return True @@ -224,19 +222,17 @@ class Timer(Entity): ATTR_REMAINING: str(self._remaining) } - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Call when entity is about to be added to Home Assistant.""" # If not None, we got an initial value. if self._state is not None: return restore_state = self._hass.helpers.restore_state - state = yield from restore_state.async_get_last_state(self.entity_id) + state = await restore_state.async_get_last_state(self.entity_id) self._state = state and state.state == state - @asyncio.coroutine - def async_start(self, duration): + async def async_start(self, duration): """Start a timer.""" if self._listener: self._listener() @@ -260,10 +256,9 @@ class Timer(Entity): self._listener = async_track_point_in_utc_time(self._hass, self.async_finished, self._end) - yield from self.async_update_ha_state() + await self.async_update_ha_state() - @asyncio.coroutine - def async_pause(self): + async def async_pause(self): """Pause a timer.""" if self._listener is None: return @@ -273,10 +268,9 @@ class Timer(Entity): self._remaining = self._end - dt_util.utcnow() self._state = STATUS_PAUSED self._end = None - yield from self.async_update_ha_state() + await self.async_update_ha_state() - @asyncio.coroutine - def async_cancel(self): + async def async_cancel(self): """Cancel a timer.""" if self._listener: self._listener() @@ -286,10 +280,9 @@ class Timer(Entity): self._remaining = timedelta() self._hass.bus.async_fire(EVENT_TIMER_CANCELLED, {"entity_id": self.entity_id}) - yield from self.async_update_ha_state() + await self.async_update_ha_state() - @asyncio.coroutine - def async_finish(self): + async def async_finish(self): """Reset and updates the states, fire finished event.""" if self._state != STATUS_ACTIVE: return @@ -299,10 +292,9 @@ class Timer(Entity): self._remaining = timedelta() self._hass.bus.async_fire(EVENT_TIMER_FINISHED, {"entity_id": self.entity_id}) - yield from self.async_update_ha_state() + await self.async_update_ha_state() - @asyncio.coroutine - def async_finished(self, time): + async def async_finished(self, time): """Reset and updates the states, fire finished event.""" if self._state != STATUS_ACTIVE: return @@ -312,4 +304,4 @@ class Timer(Entity): self._remaining = timedelta() self._hass.bus.async_fire(EVENT_TIMER_FINISHED, {"entity_id": self.entity_id}) - yield from self.async_update_ha_state() + await self.async_update_ha_state() diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index 5ac4d2a4eb1..72d1b4c769f 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -1,10 +1,9 @@ """ -Support for Ikea Tradfri. +Support for IKEA Tradfri. For more details about this component, please refer to the documentation at https://home-assistant.io/components/ikea_tradfri/ """ -import asyncio import logging from uuid import uuid4 @@ -16,7 +15,7 @@ from homeassistant.const import CONF_HOST from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['pytradfri[async]==4.1.0'] +REQUIREMENTS = ['pytradfri[async]==5.4.2'] DOMAIN = 'tradfri' GATEWAY_IDENTITY = 'homeassistant' @@ -49,8 +48,7 @@ def request_configuration(hass, config, host): if instance: return - @asyncio.coroutine - def configuration_callback(callback_data): + async def configuration_callback(callback_data): """Handle the submitted configuration.""" try: from pytradfri.api.aiocoap_api import APIFactory @@ -67,14 +65,14 @@ def request_configuration(hass, config, host): # pytradfri aiocoap API into an endless loop. # Should just raise a requestError or something. try: - key = yield from api_factory.generate_psk(security_code) + key = await api_factory.generate_psk(security_code) except RequestError: configurator.async_notify_errors(hass, instance, "Security Code not accepted.") return - res = yield from _setup_gateway(hass, config, host, identity, key, - DEFAULT_ALLOW_TRADFRI_GROUPS) + res = await _setup_gateway(hass, config, host, identity, key, + DEFAULT_ALLOW_TRADFRI_GROUPS) if not res: configurator.async_notify_errors(hass, instance, @@ -101,18 +99,16 @@ def request_configuration(hass, config, host): ) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the Tradfri component.""" conf = config.get(DOMAIN, {}) host = conf.get(CONF_HOST) allow_tradfri_groups = conf.get(CONF_ALLOW_TRADFRI_GROUPS) - known_hosts = yield from hass.async_add_job(load_json, - hass.config.path(CONFIG_FILE)) + known_hosts = await hass.async_add_job(load_json, + hass.config.path(CONFIG_FILE)) - @asyncio.coroutine - def gateway_discovered(service, info, - allow_tradfri_groups=DEFAULT_ALLOW_TRADFRI_GROUPS): + async def gateway_discovered(service, info, + allow_groups=DEFAULT_ALLOW_TRADFRI_GROUPS): """Run when a gateway is discovered.""" host = info['host'] @@ -121,23 +117,22 @@ def async_setup(hass, config): # identity was hard coded as 'homeassistant' identity = known_hosts[host].get('identity', 'homeassistant') key = known_hosts[host].get('key') - yield from _setup_gateway(hass, config, host, identity, key, - allow_tradfri_groups) + await _setup_gateway(hass, config, host, identity, key, + allow_groups) else: hass.async_add_job(request_configuration, hass, config, host) discovery.async_listen(hass, SERVICE_IKEA_TRADFRI, gateway_discovered) if host: - yield from gateway_discovered(None, - {'host': host}, - allow_tradfri_groups) + await gateway_discovered(None, + {'host': host}, + allow_tradfri_groups) return True -@asyncio.coroutine -def _setup_gateway(hass, hass_config, host, identity, key, - allow_tradfri_groups): +async def _setup_gateway(hass, hass_config, host, identity, key, + allow_tradfri_groups): """Create a gateway.""" from pytradfri import Gateway, RequestError # pylint: disable=import-error try: @@ -151,7 +146,7 @@ def _setup_gateway(hass, hass_config, host, identity, key, loop=hass.loop) api = factory.request gateway = Gateway() - gateway_info_result = yield from api(gateway.get_gateway_info()) + gateway_info_result = await api(gateway.get_gateway_info()) except RequestError: _LOGGER.exception("Tradfri setup failed.") return False diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 17aa66ea825..999b584360c 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -37,6 +37,7 @@ ATTR_CACHE = 'cache' ATTR_LANGUAGE = 'language' ATTR_MESSAGE = 'message' ATTR_OPTIONS = 'options' +ATTR_PLATFORM = 'platform' CONF_CACHE = 'cache' CONF_CACHE_DIR = 'cache_dir' @@ -77,8 +78,7 @@ SCHEMA_SERVICE_SAY = vol.Schema({ SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up TTS.""" tts = SpeechManager(hass) @@ -88,27 +88,27 @@ def async_setup(hass, config): cache_dir = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) time_memory = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) - yield from tts.async_init_cache(use_cache, cache_dir, time_memory) + await tts.async_init_cache(use_cache, cache_dir, time_memory) except (HomeAssistantError, KeyError) as err: _LOGGER.error("Error on cache init %s", err) return False hass.http.register_view(TextToSpeechView(tts)) + hass.http.register_view(TextToSpeechUrlView(tts)) - @asyncio.coroutine - def async_setup_platform(p_type, p_config, disc_info=None): + async def async_setup_platform(p_type, p_config, disc_info=None): """Set up a TTS platform.""" - platform = yield from async_prepare_setup_platform( + platform = await async_prepare_setup_platform( hass, config, DOMAIN, p_type) if platform is None: return try: if hasattr(platform, 'async_get_engine'): - provider = yield from platform.async_get_engine( + provider = await platform.async_get_engine( hass, p_config) else: - provider = yield from hass.async_add_job( + provider = await hass.async_add_job( platform.get_engine, hass, p_config) if provider is None: @@ -120,8 +120,7 @@ def async_setup(hass, config): _LOGGER.exception("Error setting up platform %s", p_type) return - @asyncio.coroutine - def async_say_handle(service): + async def async_say_handle(service): """Service handle for say.""" entity_ids = service.data.get(ATTR_ENTITY_ID) message = service.data.get(ATTR_MESSAGE) @@ -130,7 +129,7 @@ def async_setup(hass, config): options = service.data.get(ATTR_OPTIONS) try: - url = yield from tts.async_get_url( + url = await tts.async_get_url( p_type, message, cache=cache, language=language, options=options ) @@ -146,7 +145,7 @@ def async_setup(hass, config): if entity_ids: data[ATTR_ENTITY_ID] = entity_ids - yield from hass.services.async_call( + await hass.services.async_call( DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True) hass.services.async_register( @@ -157,12 +156,11 @@ def async_setup(hass, config): in config_per_platform(config, DOMAIN)] if setup_tasks: - yield from asyncio.wait(setup_tasks, loop=hass.loop) + await asyncio.wait(setup_tasks, loop=hass.loop) - @asyncio.coroutine - def async_clear_cache_handle(service): + async def async_clear_cache_handle(service): """Handle clear cache service call.""" - yield from tts.async_clear_cache() + await tts.async_clear_cache() hass.services.async_register( DOMAIN, SERVICE_CLEAR_CACHE, async_clear_cache_handle, @@ -185,8 +183,7 @@ class SpeechManager(object): self.file_cache = {} self.mem_cache = {} - @asyncio.coroutine - def async_init_cache(self, use_cache, cache_dir, time_memory): + async def async_init_cache(self, use_cache, cache_dir, time_memory): """Init config folder and load file cache.""" self.use_cache = use_cache self.time_memory = time_memory @@ -201,7 +198,7 @@ class SpeechManager(object): return cache_dir try: - self.cache_dir = yield from self.hass.async_add_job( + self.cache_dir = await self.hass.async_add_job( init_tts_cache_dir, cache_dir) except OSError as err: raise HomeAssistantError("Can't init cache dir {}".format(err)) @@ -222,15 +219,14 @@ class SpeechManager(object): return cache try: - cache_files = yield from self.hass.async_add_job(get_cache_files) + cache_files = await self.hass.async_add_job(get_cache_files) except OSError as err: raise HomeAssistantError("Can't read cache dir {}".format(err)) if cache_files: self.file_cache.update(cache_files) - @asyncio.coroutine - def async_clear_cache(self): + async def async_clear_cache(self): """Read file cache and delete files.""" self.mem_cache = {} @@ -243,7 +239,7 @@ class SpeechManager(object): _LOGGER.warning( "Can't remove cache file '%s': %s", filename, err) - yield from self.hass.async_add_job(remove_files) + await self.hass.async_add_job(remove_files) self.file_cache = {} @callback @@ -254,9 +250,8 @@ class SpeechManager(object): provider.name = engine self.providers[engine] = provider - @asyncio.coroutine - def async_get_url(self, engine, message, cache=None, language=None, - options=None): + async def async_get_url(self, engine, message, cache=None, language=None, + options=None): """Get URL for play message. This method is a coroutine. @@ -301,21 +296,20 @@ class SpeechManager(object): self.hass.async_add_job(self.async_file_to_mem(key)) # Load speech from provider into memory else: - filename = yield from self.async_get_tts_audio( + filename = await self.async_get_tts_audio( engine, key, message, use_cache, language, options) return "{}/api/tts_proxy/{}".format( self.hass.config.api.base_url, filename) - @asyncio.coroutine - def async_get_tts_audio(self, engine, key, message, cache, language, - options): + async def async_get_tts_audio(self, engine, key, message, cache, language, + options): """Receive TTS and store for view in cache. This method is a coroutine. """ provider = self.providers[engine] - extension, data = yield from provider.async_get_tts_audio( + extension, data = await provider.async_get_tts_audio( message, language, options) if data is None or extension is None: @@ -337,8 +331,7 @@ class SpeechManager(object): return filename - @asyncio.coroutine - def async_save_tts_audio(self, key, filename, data): + async def async_save_tts_audio(self, key, filename, data): """Store voice data to file and file_cache. This method is a coroutine. @@ -351,13 +344,12 @@ class SpeechManager(object): speech.write(data) try: - yield from self.hass.async_add_job(save_speech) + await self.hass.async_add_job(save_speech) self.file_cache[key] = filename except OSError: _LOGGER.error("Can't write %s", filename) - @asyncio.coroutine - def async_file_to_mem(self, key): + async def async_file_to_mem(self, key): """Load voice from file cache into memory. This method is a coroutine. @@ -374,7 +366,7 @@ class SpeechManager(object): return speech.read() try: - data = yield from self.hass.async_add_job(load_speech) + data = await self.hass.async_add_job(load_speech) except OSError: del self.file_cache[key] raise HomeAssistantError("Can't read {}".format(voice_file)) @@ -396,8 +388,7 @@ class SpeechManager(object): self.hass.loop.call_later(self.time_memory, async_remove_from_mem) - @asyncio.coroutine - def async_read_tts(self, filename): + async def async_read_tts(self, filename): """Read a voice file and return binary. This method is a coroutine. @@ -412,7 +403,7 @@ class SpeechManager(object): if key not in self.mem_cache: if key not in self.file_cache: raise HomeAssistantError("{} not in cache!".format(key)) - yield from self.async_file_to_mem(key) + await self.async_file_to_mem(key) content, _ = mimetypes.guess_type(filename) return (content, self.mem_cache[key][MEM_CACHE_VOICE]) @@ -490,6 +481,45 @@ class Provider(object): ft.partial(self.get_tts_audio, message, language, options=options)) +class TextToSpeechUrlView(HomeAssistantView): + """TTS view to get a url to a generated speech file.""" + + requires_auth = True + url = '/api/tts_get_url' + name = 'api:tts:geturl' + + def __init__(self, tts): + """Initialize a tts view.""" + self.tts = tts + + async def post(self, request): + """Generate speech and provide url.""" + try: + data = await request.json() + except ValueError: + return self.json_message('Invalid JSON specified', 400) + if not data.get(ATTR_PLATFORM) and data.get(ATTR_MESSAGE): + return self.json_message('Must specify platform and message', 400) + + p_type = data[ATTR_PLATFORM] + message = data[ATTR_MESSAGE] + cache = data.get(ATTR_CACHE) + language = data.get(ATTR_LANGUAGE) + options = data.get(ATTR_OPTIONS) + + try: + url = await self.tts.async_get_url( + p_type, message, cache=cache, language=language, + options=options + ) + resp = self.json({'url': url}, 200) + except HomeAssistantError as err: + _LOGGER.error("Error on init tts: %s", err) + resp = self.json({'error': err}, 400) + + return resp + + class TextToSpeechView(HomeAssistantView): """TTS view to serve a speech audio.""" @@ -501,11 +531,10 @@ class TextToSpeechView(HomeAssistantView): """Initialize a tts view.""" self.tts = tts - @asyncio.coroutine - def get(self, request, filename): + async def get(self, request, filename): """Start a get request.""" try: - content, data = yield from self.tts.async_read_tts(filename) + content, data = await self.tts.async_read_tts(filename) except HomeAssistantError as err: _LOGGER.error("Error on load tts: %s", err) return web.Response(status=404) diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py index d7cf0f1f2d1..46c1a24caa0 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -20,7 +20,11 @@ CONF_PROFILE_NAME = 'profile_name' ATTR_CREDENTIALS = 'credentials' DEFAULT_REGION = 'us-east-1' -SUPPORTED_REGIONS = ['us-east-1', 'us-east-2', 'us-west-2', 'eu-west-1'] +SUPPORTED_REGIONS = ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', + 'ca-central-1', 'eu-west-1', 'eu-central-1', 'eu-west-2', + 'eu-west-3', 'ap-southeast-1', 'ap-southeast-2', + 'ap-northeast-2', 'ap-northeast-1', 'ap-south-1', + 'sa-east-1'] CONF_VOICE = 'voice' CONF_OUTPUT_FORMAT = 'output_format' diff --git a/homeassistant/components/tts/google.py b/homeassistant/components/tts/google.py index 084a7229212..cb05795c445 100644 --- a/homeassistant/components/tts/google.py +++ b/homeassistant/components/tts/google.py @@ -29,7 +29,7 @@ SUPPORT_LANGUAGES = [ 'hr', 'cs', 'da', 'nl', 'en', 'en-au', 'en-uk', 'en-us', 'eo', 'fi', 'fr', 'de', 'el', 'hi', 'hu', 'is', 'id', 'it', 'ja', 'ko', 'la', 'lv', 'mk', 'no', 'pl', 'pt', 'pt-br', 'ro', 'ru', 'sr', 'sk', 'es', 'es-es', - 'es-us', 'sw', 'sv', 'ta', 'th', 'tr', 'vi', 'cy', 'uk', + 'es-us', 'sw', 'sv', 'ta', 'th', 'tr', 'vi', 'cy', 'uk', 'bg-BG' ] DEFAULT_LANG = 'en' @@ -39,8 +39,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_get_engine(hass, config): +async def async_get_engine(hass, config): """Set up Google speech component.""" return GoogleProvider(hass, config[CONF_LANG]) @@ -70,8 +69,7 @@ class GoogleProvider(Provider): """Return list of supported languages.""" return SUPPORT_LANGUAGES - @asyncio.coroutine - def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio(self, message, language, options=None): """Load TTS from google.""" from gtts_token import gtts_token @@ -81,7 +79,7 @@ class GoogleProvider(Provider): data = b'' for idx, part in enumerate(message_parts): - part_token = yield from self.hass.async_add_job( + part_token = await self.hass.async_add_job( token.calculate_token, part) url_param = { @@ -97,7 +95,7 @@ class GoogleProvider(Provider): try: with async_timeout.timeout(10, loop=self.hass.loop): - request = yield from websession.get( + request = await websession.get( GOOGLE_SPEECH_URL, params=url_param, headers=self.headers ) @@ -106,7 +104,7 @@ class GoogleProvider(Provider): _LOGGER.error("Error %d on load url %s", request.status, request.url) return (None, None) - data += yield from request.read() + data += await request.read() except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout for google speech.") diff --git a/homeassistant/components/updater.py b/homeassistant/components/updater.py index f7bf9774e42..0cb22bd98dc 100644 --- a/homeassistant/components/updater.py +++ b/homeassistant/components/updater.py @@ -25,7 +25,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['distro==1.2.0'] +REQUIREMENTS = ['distro==1.3.0'] _LOGGER = logging.getLogger(__name__) @@ -72,8 +72,7 @@ def _load_uuid(hass, filename=UPDATER_UUID_FILE): return _create_uuid(hass, filename) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the updater component.""" if 'dev' in current_version: # This component only makes sense in release versions @@ -81,16 +80,15 @@ def async_setup(hass, config): config = config.get(DOMAIN, {}) if config.get(CONF_REPORTING): - huuid = yield from hass.async_add_job(_load_uuid, hass) + huuid = await hass.async_add_job(_load_uuid, hass) else: huuid = None include_components = config.get(CONF_COMPONENT_REPORTING) - @asyncio.coroutine - def check_new_version(now): + async def check_new_version(now): """Check if a new version is available and report if one is.""" - result = yield from get_newest_version(hass, huuid, include_components) + result = await get_newest_version(hass, huuid, include_components) if result is None: return @@ -125,8 +123,7 @@ def async_setup(hass, config): return True -@asyncio.coroutine -def get_system_info(hass, include_components): +async def get_system_info(hass, include_components): """Return info about the system.""" info_object = { 'arch': platform.machine(), @@ -151,7 +148,7 @@ def get_system_info(hass, include_components): info_object['os_version'] = platform.release() elif platform.system() == 'Linux': import distro - linux_dist = yield from hass.async_add_job( + linux_dist = await hass.async_add_job( distro.linux_distribution, False) info_object['distribution'] = linux_dist[0] info_object['os_version'] = linux_dist[1] @@ -160,11 +157,10 @@ def get_system_info(hass, include_components): return info_object -@asyncio.coroutine -def get_newest_version(hass, huuid, include_components): +async def get_newest_version(hass, huuid, include_components): """Get the newest Home Assistant version.""" if huuid: - info_object = yield from get_system_info(hass, include_components) + info_object = await get_system_info(hass, include_components) info_object['huuid'] = huuid else: info_object = {} @@ -172,7 +168,7 @@ def get_newest_version(hass, huuid, include_components): session = async_get_clientsession(hass) try: with async_timeout.timeout(5, loop=hass.loop): - req = yield from session.post(UPDATER_URL, json=info_object) + req = await session.post(UPDATER_URL, json=info_object) _LOGGER.info(("Submitted analytics to Home Assistant servers. " "Information submitted includes %s"), info_object) except (asyncio.TimeoutError, aiohttp.ClientError): @@ -181,7 +177,7 @@ def get_newest_version(hass, huuid, include_components): return None try: - res = yield from req.json() + res = await req.json() except ValueError: _LOGGER.error("Received invalid JSON from Home Assistant Update") return None diff --git a/homeassistant/components/upnp.py b/homeassistant/components/upnp.py index 960d8f3780e..8aeb93fed25 100644 --- a/homeassistant/components/upnp.py +++ b/homeassistant/components/upnp.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/upnp/ """ from ipaddress import ip_address import logging +import asyncio import voluptuous as vol @@ -14,7 +15,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery from homeassistant.util import get_local_ip -REQUIREMENTS = ['miniupnpc==2.0.2'] +REQUIREMENTS = ['pyupnp-async==0.1.0.2'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -22,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['api'] DOMAIN = 'upnp' -DATA_UPNP = 'UPNP' +DATA_UPNP = 'upnp_device' CONF_LOCAL_IP = 'local_ip' CONF_ENABLE_PORT_MAPPING = 'port_mapping' @@ -33,6 +34,11 @@ CONF_HASS = 'hass' NOTIFICATION_ID = 'upnp_notification' NOTIFICATION_TITLE = 'UPnP Setup' +IGD_DEVICE = 'urn:schemas-upnp-org:device:InternetGatewayDevice:1' +PPP_SERVICE = 'urn:schemas-upnp-org:service:WANPPPConnection:1' +IP_SERVICE = 'urn:schemas-upnp-org:service:WANIPConnection:1' +CIC_SERVICE = 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1' + UNITS = { "Bytes": 1, "KBytes": 1024, @@ -44,22 +50,19 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_ENABLE_PORT_MAPPING, default=True): cv.boolean, vol.Optional(CONF_UNITS, default="MBytes"): vol.In(UNITS), - vol.Optional(CONF_LOCAL_IP): ip_address, + vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string), vol.Optional(CONF_PORTS): vol.Schema({vol.Any(CONF_HASS, cv.positive_int): cv.positive_int}) }), }, extra=vol.ALLOW_EXTRA) -# pylint: disable=import-error, no-member, broad-except, c-extension-no-member -def setup(hass, config): +async def async_setup(hass, config): """Register a port mapping for Home Assistant via UPnP.""" config = config[DOMAIN] host = config.get(CONF_LOCAL_IP) - if host is not None: - host = str(host) - else: + if host is None: host = get_local_ip() if host == '127.0.0.1': @@ -67,21 +70,33 @@ def setup(hass, config): 'Unable to determine local IP. Add it to your configuration.') return False - import miniupnpc + import pyupnp_async + from pyupnp_async.error import UpnpSoapError - upnp = miniupnpc.UPnP() - hass.data[DATA_UPNP] = upnp - - upnp.discoverdelay = 200 - upnp.discover() - try: - upnp.selectigd() - except Exception: - _LOGGER.exception("Error when attempting to discover an UPnP IGD") + service = None + resp = await pyupnp_async.msearch_first(search_target=IGD_DEVICE) + if not resp: return False - unit = config.get(CONF_UNITS) - discovery.load_platform(hass, 'sensor', DOMAIN, {'unit': unit}, config) + try: + device = await resp.get_device() + hass.data[DATA_UPNP] = device + for _service in device.services: + if _service['serviceType'] == PPP_SERVICE: + service = device.find_first_service(PPP_SERVICE) + if _service['serviceType'] == IP_SERVICE: + service = device.find_first_service(IP_SERVICE) + if _service['serviceType'] == CIC_SERVICE: + unit = config.get(CONF_UNITS) + hass.async_add_job(discovery.async_load_platform( + hass, 'sensor', DOMAIN, {'unit': unit}, config)) + except UpnpSoapError as error: + _LOGGER.error(error) + return False + + if not service: + _LOGGER.warning("Could not find any UPnP IGD") + return False port_mapping = config.get(CONF_ENABLE_PORT_MAPPING) if not port_mapping: @@ -98,12 +113,12 @@ def setup(hass, config): if internal == CONF_HASS: internal = internal_port try: - upnp.addportmapping( - external, 'TCP', host, internal, 'Home Assistant', '') + await service.add_port_mapping(internal, external, host, 'TCP', + desc='Home Assistant') registered.append(external) - except Exception: - _LOGGER.exception("UPnP failed to configure port mapping for %s", - external) + _LOGGER.debug("external %s -> %s @ %s", external, internal, host) + except UpnpSoapError as error: + _LOGGER.error(error) hass.components.persistent_notification.create( 'ERROR: tcp port {} is already mapped in your router.' '
Please disable port_mapping in the upnp ' @@ -113,11 +128,13 @@ def setup(hass, config): title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) - def deregister_port(event): + async def deregister_port(event): """De-register the UPnP port mapping.""" - for external in registered: - upnp.deleteportmapping(external, 'TCP') + tasks = [service.delete_port_mapping(external, 'TCP') + for external in registered] + if tasks: + await asyncio.wait(tasks) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port) return True diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 095e8bfb124..1b7d5685231 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -57,7 +57,7 @@ VACUUM_SET_FAN_SPEED_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({ VACUUM_SEND_COMMAND_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({ vol.Required(ATTR_COMMAND): cv.string, - vol.Optional(ATTR_PARAMS): cv.Dict, + vol.Optional(ATTR_PARAMS): vol.Any(cv.Dict, cv.ensure_list), }) SERVICE_TO_METHOD = { @@ -76,7 +76,6 @@ SERVICE_TO_METHOD = { } DEFAULT_NAME = 'Vacuum cleaner robot' -DEFAULT_ICON = 'mdi:roomba' SUPPORT_TURN_ON = 1 SUPPORT_TURN_OFF = 2 diff --git a/homeassistant/components/vacuum/demo.py b/homeassistant/components/vacuum/demo.py index 668e3ca37e6..bd501167ffa 100644 --- a/homeassistant/components/vacuum/demo.py +++ b/homeassistant/components/vacuum/demo.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/demo/ import logging from homeassistant.components.vacuum import ( - ATTR_CLEANED_AREA, DEFAULT_ICON, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, + ATTR_CLEANED_AREA, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, VacuumDevice) @@ -66,11 +66,6 @@ class DemoVacuum(VacuumDevice): """Return the name of the vacuum.""" return self._name - @property - def icon(self): - """Return the icon for the vacuum.""" - return DEFAULT_ICON - @property def should_poll(self): """No polling needed for a demo vacuum.""" diff --git a/homeassistant/components/vacuum/dyson.py b/homeassistant/components/vacuum/dyson.py index aa05d004a35..d423a8dacf5 100644 --- a/homeassistant/components/vacuum/dyson.py +++ b/homeassistant/components/vacuum/dyson.py @@ -24,8 +24,6 @@ DEPENDENCIES = ['dyson'] DYSON_360_EYE_DEVICES = "dyson_360_eye_devices" -ICON = 'mdi:roomba' - SUPPORT_DYSON = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PAUSE | \ SUPPORT_RETURN_HOME | SUPPORT_FAN_SPEED | SUPPORT_STATUS | \ SUPPORT_BATTERY | SUPPORT_STOP @@ -56,7 +54,6 @@ class Dyson360EyeDevice(VacuumDevice): """Dyson 360 Eye robot vacuum device.""" _LOGGER.debug("Creating device %s", device.name) self._device = device - self._icon = ICON @asyncio.coroutine def async_added_to_hass(self): @@ -82,11 +79,6 @@ class Dyson360EyeDevice(VacuumDevice): """Return the name of the device.""" return self._device.name - @property - def icon(self): - """Return the icon to use for device.""" - return self._icon - @property def status(self): """Return the status of the vacuum cleaner.""" diff --git a/homeassistant/components/vacuum/mqtt.py b/homeassistant/components/vacuum/mqtt.py index f4c640f1fc7..ef3bb0f636b 100644 --- a/homeassistant/components/vacuum/mqtt.py +++ b/homeassistant/components/vacuum/mqtt.py @@ -12,7 +12,7 @@ import voluptuous as vol import homeassistant.components.mqtt as mqtt from homeassistant.components.mqtt import MqttAvailability from homeassistant.components.vacuum import ( - DEFAULT_ICON, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, + SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, VacuumDevice) @@ -340,11 +340,6 @@ class MqttVacuum(MqttAvailability, VacuumDevice): """Return the name of the vacuum.""" return self._name - @property - def icon(self): - """Return the icon for the vacuum.""" - return DEFAULT_ICON - @property def should_poll(self): """No polling needed for an MQTT vacuum.""" diff --git a/homeassistant/components/vacuum/neato.py b/homeassistant/components/vacuum/neato.py index 2a4eb2d5e7f..9eba34cea32 100644 --- a/homeassistant/components/vacuum/neato.py +++ b/homeassistant/components/vacuum/neato.py @@ -24,8 +24,6 @@ SUPPORT_NEATO = SUPPORT_BATTERY | SUPPORT_PAUSE | SUPPORT_RETURN_HOME | \ SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \ SUPPORT_STATUS | SUPPORT_MAP -ICON = 'mdi:roomba' - ATTR_CLEAN_START = 'clean_start' ATTR_CLEAN_STOP = 'clean_stop' ATTR_CLEAN_AREA = 'clean_area' @@ -131,11 +129,6 @@ class NeatoConnectedVacuum(VacuumDevice): """Return the name of the device.""" return self._name - @property - def icon(self): - """Return the icon to use for device.""" - return ICON - @property def supported_features(self): """Flag vacuum cleaner robot features that are supported.""" diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index b983b20bd0c..44d22e03f41 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -43,7 +43,6 @@ DEFAULT_CERT = '/etc/ssl/certs/ca-certificates.crt' DEFAULT_CONTINUOUS = True DEFAULT_NAME = 'Roomba' -ICON = 'mdi:roomba' PLATFORM = 'roomba' FAN_SPEED_AUTOMATIC = 'Automatic' @@ -165,11 +164,6 @@ class RoombaVacuum(VacuumDevice): """Return the name of the device.""" return self._name - @property - def icon(self): - """Return the icon to use for device.""" - return ICON - @property def device_state_attributes(self): """Return the state attributes of the device.""" diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index fea365ac7c7..863157074bc 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -4,93 +4,93 @@ turn_on: description: Start a new cleaning task. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' turn_off: description: Stop the current cleaning task and return to home. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' stop: description: Stop the current cleaning task. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' locate: description: Locate the vacuum cleaner robot. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' start_pause: description: Start, pause, or resume the cleaning task. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' return_to_base: description: Tell the vacuum cleaner to return to its dock. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' clean_spot: description: Tell the vacuum cleaner to do a spot clean-up. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' send_command: description: Send a raw command to the vacuum cleaner. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' command: description: Command to execute. example: 'set_dnd_timer' params: description: Parameters for the command. - example: '[22,0,6,0]' + example: '{ "key": "value" }' set_fan_speed: description: Set the fan speed of the vacuum cleaner. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' fan_speed: - description: Platform dependent vacuum cleaner fan speed, with speed steps, like 'medium', or by percentage, between 0 and 100. + description: Platform dependent vacuum cleaner fan speed, with speed steps, like 'medium' or by percentage, between 0 and 100. example: 'low' xiaomi_remote_control_start: description: Start remote control of the vacuum cleaner. You can then move it with `remote_control_move`, when done call `remote_control_stop`. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' xiaomi_remote_control_stop: description: Stop remote control mode of the vacuum cleaner. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' xiaomi_remote_control_move: description: Remote control the vacuum cleaner, make sure you first set it in remote control mode with `remote_control_start`. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' velocity: description: Speed, between -0.29 and 0.29. @@ -106,7 +106,7 @@ xiaomi_remote_control_move_step: description: Remote control the vacuum cleaner, only makes one move and then stops. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' velocity: description: Speed, between -0.29 and 0.29. diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index f42a895f94f..620014a1bae 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -19,12 +19,11 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.3.8'] +REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Vacuum cleaner' -ICON = 'mdi:roomba' DATA_KEY = 'vacuum.xiaomi_miio' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -142,7 +141,6 @@ class MiroboVacuum(VacuumDevice): def __init__(self, name, vacuum): """Initialize the Xiaomi vacuum cleaner robot handler.""" self._name = name - self._icon = ICON self._vacuum = vacuum self.vacuum_state = None @@ -158,11 +156,6 @@ class MiroboVacuum(VacuumDevice): """Return the name of the device.""" return self._name - @property - def icon(self): - """Return the icon to use for device.""" - return self._icon - @property def status(self): """Return the status of the vacuum cleaner.""" diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 5cc4de0d5ca..ebe92a2dcc2 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -19,7 +19,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.42'] +REQUIREMENTS = ['pyvera==0.2.43'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index b200d634ba9..c36c960c4fc 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -22,7 +22,10 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' ATTR_CONDITION_CLASS = 'condition_class' ATTR_FORECAST = 'forecast' +ATTR_FORECAST_CONDITION = 'condition' +ATTR_FORECAST_PRECIPITATION = 'precipitation' ATTR_FORECAST_TEMP = 'temperature' +ATTR_FORECAST_TEMP_LOW = 'templow' ATTR_FORECAST_TIME = 'datetime' ATTR_WEATHER_ATTRIBUTION = 'attribution' ATTR_WEATHER_HUMIDITY = 'humidity' @@ -110,9 +113,12 @@ class WeatherEntity(Entity): ATTR_WEATHER_TEMPERATURE: show_temp( self.hass, self.temperature, self.temperature_unit, self.precision), - ATTR_WEATHER_HUMIDITY: round(self.humidity) } + humidity = self.humidity + if humidity is not None: + data[ATTR_WEATHER_HUMIDITY] = round(humidity) + ozone = self.ozone if ozone is not None: data[ATTR_WEATHER_OZONE] = ozone @@ -144,6 +150,10 @@ class WeatherEntity(Entity): forecast_entry[ATTR_FORECAST_TEMP] = show_temp( self.hass, forecast_entry[ATTR_FORECAST_TEMP], self.temperature_unit, self.precision) + if ATTR_FORECAST_TEMP_LOW in forecast_entry: + forecast_entry[ATTR_FORECAST_TEMP_LOW] = show_temp( + self.hass, forecast_entry[ATTR_FORECAST_TEMP_LOW], + self.temperature_unit, self.precision) forecast.append(forecast_entry) data[ATTR_FORECAST] = forecast diff --git a/homeassistant/components/weather/bom.py b/homeassistant/components/weather/bom.py index 236aeb2fa2e..ad74bb4fb77 100644 --- a/homeassistant/components/weather/bom.py +++ b/homeassistant/components/weather/bom.py @@ -48,7 +48,7 @@ class BOMWeather(WeatherEntity): def __init__(self, bom_data, stationname=None): """Initialise the platform with a data instance and station name.""" self.bom_data = bom_data - self.stationname = stationname or self.bom_data.data.get('name') + self.stationname = stationname or self.bom_data.latest_data.get('name') def update(self): """Update current conditions.""" @@ -62,14 +62,14 @@ class BOMWeather(WeatherEntity): @property def condition(self): """Return the current condition.""" - return self.bom_data.data.get('weather') + return self.bom_data.get_reading('weather') # Now implement the WeatherEntity interface @property def temperature(self): """Return the platform temperature.""" - return self.bom_data.data.get('air_temp') + return self.bom_data.get_reading('air_temp') @property def temperature_unit(self): @@ -79,17 +79,17 @@ class BOMWeather(WeatherEntity): @property def pressure(self): """Return the mean sea-level pressure.""" - return self.bom_data.data.get('press_msl') + return self.bom_data.get_reading('press_msl') @property def humidity(self): """Return the relative humidity.""" - return self.bom_data.data.get('rel_hum') + return self.bom_data.get_reading('rel_hum') @property def wind_speed(self): """Return the wind speed.""" - return self.bom_data.data.get('wind_spd_kmh') + return self.bom_data.get_reading('wind_spd_kmh') @property def wind_bearing(self): @@ -99,7 +99,7 @@ class BOMWeather(WeatherEntity): 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] wind = {name: idx * 360 / 16 for idx, name in enumerate(directions)} - return wind.get(self.bom_data.data.get('wind_dir')) + return wind.get(self.bom_data.get_reading('wind_dir')) @property def attribution(self): diff --git a/homeassistant/components/weather/buienradar.py b/homeassistant/components/weather/buienradar.py index a49a1664eec..9b9707e87f6 100644 --- a/homeassistant/components/weather/buienradar.py +++ b/homeassistant/components/weather/buienradar.py @@ -10,7 +10,8 @@ import asyncio import voluptuous as vol from homeassistant.components.weather import ( - WeatherEntity, PLATFORM_SCHEMA, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME) + WeatherEntity, PLATFORM_SCHEMA, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME) from homeassistant.const import \ CONF_NAME, TEMP_CELSIUS, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.helpers import config_validation as cv @@ -28,9 +29,6 @@ DEFAULT_TIMEFRAME = 60 CONF_FORECAST = 'forecast' -ATTR_FORECAST_CONDITION = 'condition' -ATTR_FORECAST_TEMP_LOW = 'templow' - CONDITION_CLASSES = { 'cloudy': ['c', 'p'], @@ -121,15 +119,6 @@ class BrWeather(WeatherEntity): if conditions: return conditions.get(ccode) - @property - def entity_picture(self): - """Return the entity picture to use in the frontend, if any.""" - from buienradar.buienradar import (IMAGE) - - if self._data and self._data.condition: - return self._data.condition.get(IMAGE, None) - return None - @property def temperature(self): """Return the current temperature.""" diff --git a/homeassistant/components/weather/darksky.py b/homeassistant/components/weather/darksky.py index 139f8abfce6..f0712542ea5 100644 --- a/homeassistant/components/weather/darksky.py +++ b/homeassistant/components/weather/darksky.py @@ -19,15 +19,12 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['python-forecastio==1.3.5'] +REQUIREMENTS = ['python-forecastio==1.4.0'] _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Powered by Dark Sky" -ATTR_DAILY_FORECAST_SUMMARY = 'daily_forecast_summary' -ATTR_HOURLY_FORECAST_SUMMARY = 'hourly_forecast_summary' - CONF_UNITS = 'units' DEFAULT_NAME = 'Dark Sky' @@ -122,25 +119,6 @@ class DarkSkyWeather(WeatherEntity): ATTR_FORECAST_TEMP: entry.d.get('temperature')} for entry in self._ds_hourly.data] - @property - def hourly_forecast_summary(self): - """Return a summary of the hourly forecast.""" - return self._ds_hourly.summary - - @property - def daily_forecast_summary(self): - """Return a summary of the daily forecast.""" - return self._ds_daily.summary - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = { - ATTR_DAILY_FORECAST_SUMMARY: self.daily_forecast_summary, - ATTR_HOURLY_FORECAST_SUMMARY: self.hourly_forecast_summary - } - return attrs - def update(self): """Get the latest data from Dark Sky.""" self._dark_sky.update() diff --git a/homeassistant/components/weather/demo.py b/homeassistant/components/weather/demo.py index 02e07996213..fffdf03d07d 100644 --- a/homeassistant/components/weather/demo.py +++ b/homeassistant/components/weather/demo.py @@ -7,7 +7,8 @@ https://home-assistant.io/components/demo/ from datetime import datetime, timedelta from homeassistant.components.weather import ( - WeatherEntity, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME) + WeatherEntity, ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME) from homeassistant.const import (TEMP_CELSIUS, TEMP_FAHRENHEIT) CONDITION_CLASSES = { @@ -32,9 +33,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo weather.""" add_devices([ DemoWeather('South', 'Sunshine', 21.6414, 92, 1099, 0.5, TEMP_CELSIUS, - [22, 19, 15, 12, 14, 18, 21]), + [['rainy', 1, 22, 15], ['rainy', 5, 19, 8], + ['cloudy', 0, 15, 9], ['sunny', 0, 12, 6], + ['partlycloudy', 2, 14, 7], ['rainy', 15, 18, 7], + ['fog', 0.2, 21, 12]]), DemoWeather('North', 'Shower rain', -12, 54, 987, 4.8, TEMP_FAHRENHEIT, - [-10, -13, -18, -23, -19, -14, -9]) + [['snowy', 2, -10, -15], ['partlycloudy', 1, -13, -14], + ['sunny', 0, -18, -22], ['sunny', 0.1, -23, -23], + ['snowy', 4, -19, -20], ['sunny', 0.3, -14, -19], + ['sunny', 0, -9, -12]]) ]) @@ -108,7 +115,10 @@ class DemoWeather(WeatherEntity): for entry in self._forecast: data_dict = { ATTR_FORECAST_TIME: reftime.isoformat(), - ATTR_FORECAST_TEMP: entry + ATTR_FORECAST_CONDITION: entry[0], + ATTR_FORECAST_PRECIPITATION: entry[1], + ATTR_FORECAST_TEMP: entry[2], + ATTR_FORECAST_TEMP_LOW: entry[3] } reftime = reftime + timedelta(hours=4) forecast_data.append(data_dict) diff --git a/homeassistant/components/weather/ecobee.py b/homeassistant/components/weather/ecobee.py index 379f5c1211b..80ee4c29fbe 100644 --- a/homeassistant/components/weather/ecobee.py +++ b/homeassistant/components/weather/ecobee.py @@ -6,14 +6,13 @@ https://home-assistant.io/components/weather.ecobee/ """ from homeassistant.components import ecobee from homeassistant.components.weather import ( - WeatherEntity, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME) + WeatherEntity, ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME) from homeassistant.const import (TEMP_FAHRENHEIT) DEPENDENCIES = ['ecobee'] -ATTR_FORECAST_CONDITION = 'condition' -ATTR_FORECAST_TEMP_LOW = 'templow' ATTR_FORECAST_TEMP_HIGH = 'temphigh' ATTR_FORECAST_PRESSURE = 'pressure' ATTR_FORECAST_VISIBILITY = 'visibility' diff --git a/homeassistant/components/weather/ipma.py b/homeassistant/components/weather/ipma.py new file mode 100644 index 00000000000..ef4f1b349d7 --- /dev/null +++ b/homeassistant/components/weather/ipma.py @@ -0,0 +1,172 @@ +""" +Support for IPMA weather service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/weather.ipma/ +""" +import logging +from datetime import timedelta + +import async_timeout +import voluptuous as vol + +from homeassistant.components.weather import ( + WeatherEntity, PLATFORM_SCHEMA, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME) +from homeassistant.const import \ + CONF_NAME, TEMP_CELSIUS, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import config_validation as cv +from homeassistant.util import Throttle + +REQUIREMENTS = ['pyipma==1.1.3'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = 'Instituto Português do Mar e Atmosfera' + +ATTR_WEATHER_DESCRIPTION = "description" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) + +CONDITION_CLASSES = { + 'cloudy': [4, 5, 24, 25, 27], + 'fog': [16, 17, 26], + 'hail': [21, 22], + 'lightning': [19], + 'lightning-rainy': [20, 23], + 'partlycloudy': [2, 3], + 'pouring': [8, 11], + 'rainy': [6, 7, 9, 10, 12, 13, 14, 15], + 'snowy': [18], + 'snowy-rainy': [], + 'sunny': [1], + 'windy': [], + 'windy-variant': [], + 'exceptional': [], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the ipma platform.""" + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + + if None in (latitude, longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return + + from pyipma import Station + + websession = async_get_clientsession(hass) + with async_timeout.timeout(10, loop=hass.loop): + station = await Station.get(websession, float(latitude), + float(longitude)) + + _LOGGER.debug("Initializing ipma weather: coordinates %s, %s", + latitude, longitude) + + async_add_devices([IPMAWeather(station, config)], True) + + +class IPMAWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, station, config): + """Initialise the platform with a data instance and station name.""" + self._station_name = config.get(CONF_NAME, station.local) + self._station = station + self._condition = None + self._forecast = None + self._description = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Update Condition and Forecast.""" + with async_timeout.timeout(10, loop=self.hass.loop): + self._condition = await self._station.observation() + self._forecast = await self._station.forecast() + self._description = self._forecast[0].description + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def name(self): + """Return the name of the station.""" + return self._station_name + + @property + def condition(self): + """Return the current condition.""" + return next((k for k, v in CONDITION_CLASSES.items() + if self._forecast[0].idWeatherType in v), None) + + @property + def temperature(self): + """Return the current temperature.""" + return self._condition.temperature + + @property + def pressure(self): + """Return the current pressure.""" + return self._condition.pressure + + @property + def humidity(self): + """Return the name of the sensor.""" + return self._condition.humidity + + @property + def wind_speed(self): + """Return the current windspeed.""" + return self._condition.windspeed + + @property + def wind_bearing(self): + """Return the current wind bearing (degrees).""" + return self._condition.winddirection + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def forecast(self): + """Return the forecast array.""" + if self._forecast: + fcdata_out = [] + for data_in in self._forecast: + data_out = {} + data_out[ATTR_FORECAST_TIME] = data_in.forecastDate + data_out[ATTR_FORECAST_CONDITION] =\ + next((k for k, v in CONDITION_CLASSES.items() + if int(data_in.idWeatherType) in v), None) + data_out[ATTR_FORECAST_TEMP_LOW] = data_in.tMin + data_out[ATTR_FORECAST_TEMP] = data_in.tMax + data_out[ATTR_FORECAST_PRECIPITATION] = data_in.precipitaProb + + fcdata_out.append(data_out) + + return fcdata_out + + @property + def device_state_attributes(self): + """Return the state attributes.""" + data = dict() + + if self._description: + data[ATTR_WEATHER_DESCRIPTION] = self._description + + return data diff --git a/homeassistant/components/weather/openweathermap.py b/homeassistant/components/weather/openweathermap.py index c8a1bdf8f68..909f123b52c 100644 --- a/homeassistant/components/weather/openweathermap.py +++ b/homeassistant/components/weather/openweathermap.py @@ -10,7 +10,8 @@ import logging import voluptuous as vol from homeassistant.components.weather import ( - ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) + ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, + ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, STATE_UNKNOWN, TEMP_CELSIUS) @@ -21,14 +22,12 @@ REQUIREMENTS = ['pyowm==2.8.0'] _LOGGER = logging.getLogger(__name__) -ATTR_FORECAST_CONDITION = 'condition' ATTRIBUTION = 'Data provided by OpenWeatherMap' DEFAULT_NAME = 'OpenWeatherMap' MIN_TIME_BETWEEN_FORECAST_UPDATES = timedelta(minutes=30) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) -MIN_OFFSET_BETWEEN_FORECAST_CONDITIONS = 3 CONDITION_CLASSES = { 'cloudy': [804], @@ -144,12 +143,12 @@ class OpenWeatherMapWeather(WeatherEntity): data.append({ ATTR_FORECAST_TIME: entry.get_reference_time('unix') * 1000, ATTR_FORECAST_TEMP: - entry.get_temperature('celsius').get('temp') - }) - if (len(data) - 1) % MIN_OFFSET_BETWEEN_FORECAST_CONDITIONS == 0: - data[len(data) - 1][ATTR_FORECAST_CONDITION] = \ + entry.get_temperature('celsius').get('temp'), + ATTR_FORECAST_PRECIPITATION: entry.get_rain().get('3h'), + ATTR_FORECAST_CONDITION: [k for k, v in CONDITION_CLASSES.items() if entry.get_weather_code() in v][0] + }) return data def update(self): diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index f9610e469b2..f9befece5a4 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -10,7 +10,8 @@ import logging import voluptuous as vol from homeassistant.components.weather import ( - ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) + ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) from homeassistant.const import CONF_NAME, STATE_UNKNOWN, TEMP_CELSIUS import homeassistant.helpers.config_validation as cv @@ -20,10 +21,8 @@ _LOGGER = logging.getLogger(__name__) DATA_CONDITION = 'yahoo_condition' -ATTR_FORECAST_CONDITION = 'condition' ATTRIBUTION = "Weather details provided by Yahoo! Inc." -ATTR_FORECAST_TEMP_LOW = 'templow' CONF_WOEID = 'woeid' @@ -32,6 +31,7 @@ DEFAULT_NAME = 'Yweather' SCAN_INTERVAL = timedelta(minutes=10) CONDITION_CLASSES = { + 'clear-night': [31], 'cloudy': [26, 27, 28, 29, 30], 'fog': [19, 20, 21, 22, 23], 'hail': [17, 18, 35], diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index 1e23ad19897..11094acd3e2 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -18,8 +18,8 @@ from voluptuous.humanize import humanize_error from homeassistant.const import ( MATCH_ALL, EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, __version__) -from homeassistant.components import frontend from homeassistant.core import callback +from homeassistant.loader import bind_hass from homeassistant.remote import JSONEncoder from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_get_all_descriptions @@ -46,7 +46,6 @@ TYPE_AUTH_REQUIRED = 'auth_required' TYPE_CALL_SERVICE = 'call_service' TYPE_EVENT = 'event' TYPE_GET_CONFIG = 'get_config' -TYPE_GET_PANELS = 'get_panels' TYPE_GET_SERVICES = 'get_services' TYPE_GET_STATES = 'get_states' TYPE_PING = 'ping' @@ -61,65 +60,60 @@ JSON_DUMP = partial(json.dumps, cls=JSONEncoder) AUTH_MESSAGE_SCHEMA = vol.Schema({ vol.Required('type'): TYPE_AUTH, - vol.Required('api_password'): str, + vol.Exclusive('api_password', 'auth'): str, + vol.Exclusive('access_token', 'auth'): str, }) -SUBSCRIBE_EVENTS_MESSAGE_SCHEMA = vol.Schema({ +# Minimal requirements of a message +MINIMAL_MESSAGE_SCHEMA = vol.Schema({ vol.Required('id'): cv.positive_int, + vol.Required('type'): cv.string, +}, extra=vol.ALLOW_EXTRA) +# Base schema to extend by message handlers +BASE_COMMAND_MESSAGE_SCHEMA = vol.Schema({ + vol.Required('id'): cv.positive_int, +}) + + +SCHEMA_SUBSCRIBE_EVENTS = BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): TYPE_SUBSCRIBE_EVENTS, vol.Optional('event_type', default=MATCH_ALL): str, }) -UNSUBSCRIBE_EVENTS_MESSAGE_SCHEMA = vol.Schema({ - vol.Required('id'): cv.positive_int, + +SCHEMA_UNSUBSCRIBE_EVENTS = BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): TYPE_UNSUBSCRIBE_EVENTS, vol.Required('subscription'): cv.positive_int, }) -CALL_SERVICE_MESSAGE_SCHEMA = vol.Schema({ - vol.Required('id'): cv.positive_int, + +SCHEMA_CALL_SERVICE = BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): TYPE_CALL_SERVICE, vol.Required('domain'): str, vol.Required('service'): str, vol.Optional('service_data'): dict }) -GET_STATES_MESSAGE_SCHEMA = vol.Schema({ - vol.Required('id'): cv.positive_int, + +SCHEMA_GET_STATES = BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): TYPE_GET_STATES, }) -GET_SERVICES_MESSAGE_SCHEMA = vol.Schema({ - vol.Required('id'): cv.positive_int, + +SCHEMA_GET_SERVICES = BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): TYPE_GET_SERVICES, }) -GET_CONFIG_MESSAGE_SCHEMA = vol.Schema({ - vol.Required('id'): cv.positive_int, + +SCHEMA_GET_CONFIG = BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): TYPE_GET_CONFIG, }) -GET_PANELS_MESSAGE_SCHEMA = vol.Schema({ - vol.Required('id'): cv.positive_int, - vol.Required('type'): TYPE_GET_PANELS, -}) -PING_MESSAGE_SCHEMA = vol.Schema({ - vol.Required('id'): cv.positive_int, +SCHEMA_PING = BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): TYPE_PING, }) -BASE_COMMAND_MESSAGE_SCHEMA = vol.Schema({ - vol.Required('id'): cv.positive_int, - vol.Required('type'): vol.Any(TYPE_CALL_SERVICE, - TYPE_SUBSCRIBE_EVENTS, - TYPE_UNSUBSCRIBE_EVENTS, - TYPE_GET_STATES, - TYPE_GET_SERVICES, - TYPE_GET_CONFIG, - TYPE_GET_PANELS, - TYPE_PING) -}, extra=vol.ALLOW_EXTRA) # Define the possible errors that occur when connections are cancelled. # Originally, this was just asyncio.CancelledError, but issue #9546 showed @@ -191,9 +185,36 @@ def result_message(iden, result=None): } +@bind_hass +@callback +def async_register_command(hass, command, handler, schema): + """Register a websocket command.""" + handlers = hass.data.get(DOMAIN) + if handlers is None: + handlers = hass.data[DOMAIN] = {} + handlers[command] = (handler, schema) + + async def async_setup(hass, config): """Initialize the websocket API.""" hass.http.register_view(WebsocketAPIView) + + async_register_command(hass, TYPE_SUBSCRIBE_EVENTS, + handle_subscribe_events, SCHEMA_SUBSCRIBE_EVENTS) + async_register_command(hass, TYPE_UNSUBSCRIBE_EVENTS, + handle_unsubscribe_events, + SCHEMA_UNSUBSCRIBE_EVENTS) + async_register_command(hass, TYPE_CALL_SERVICE, + handle_call_service, SCHEMA_CALL_SERVICE) + async_register_command(hass, TYPE_GET_STATES, + handle_get_states, SCHEMA_GET_STATES) + async_register_command(hass, TYPE_GET_SERVICES, + handle_get_services, SCHEMA_GET_SERVICES) + async_register_command(hass, TYPE_GET_CONFIG, + handle_get_config, SCHEMA_GET_CONFIG) + async_register_command(hass, TYPE_PING, + handle_ping, SCHEMA_PING) + return True @@ -298,15 +319,18 @@ class ActiveConnection: msg = await wsock.receive_json() msg = AUTH_MESSAGE_SCHEMA(msg) - if validate_password(request, msg['api_password']): - authenticated = True + if 'api_password' in msg: + authenticated = validate_password( + request, msg['api_password']) - else: - self.debug("Invalid password") - await self.wsock.send_json( - auth_invalid_message('Invalid password')) + elif 'access_token' in msg: + authenticated = \ + msg['access_token'] in self.hass.auth.access_tokens if not authenticated: + self.debug("Invalid password") + await self.wsock.send_json( + auth_invalid_message('Invalid password')) await process_wrong_login(request) return wsock @@ -316,10 +340,11 @@ class ActiveConnection: msg = await wsock.receive_json() last_id = 0 + handlers = self.hass.data[DOMAIN] while msg: self.debug("Received", msg) - msg = BASE_COMMAND_MESSAGE_SCHEMA(msg) + msg = MINIMAL_MESSAGE_SCHEMA(msg) cur_id = msg['id'] if cur_id <= last_id: @@ -327,9 +352,13 @@ class ActiveConnection: cur_id, ERR_ID_REUSE, 'Identifier values have to increase.')) + elif msg['type'] not in handlers: + # Unknown command + break + else: - handler_name = 'handle_{}'.format(msg['type']) - getattr(self, handler_name)(msg) + handler, schema = handlers[msg['type']] + handler(self.hass, self, schema(msg)) last_id = cur_id msg = await wsock.receive_json() @@ -403,109 +432,96 @@ class ActiveConnection: return wsock - def handle_subscribe_events(self, msg): - """Handle subscribe events command. - Async friendly. - """ - msg = SUBSCRIBE_EVENTS_MESSAGE_SCHEMA(msg) +@callback +def handle_subscribe_events(hass, connection, msg): + """Handle subscribe events command. - async def forward_events(event): - """Forward events to websocket.""" - if event.event_type == EVENT_TIME_CHANGED: - return + Async friendly. + """ + async def forward_events(event): + """Forward events to websocket.""" + if event.event_type == EVENT_TIME_CHANGED: + return - self.send_message_outside(event_message(msg['id'], event)) + connection.send_message_outside(event_message(msg['id'], event)) - self.event_listeners[msg['id']] = self.hass.bus.async_listen( - msg['event_type'], forward_events) + connection.event_listeners[msg['id']] = hass.bus.async_listen( + msg['event_type'], forward_events) - self.to_write.put_nowait(result_message(msg['id'])) + connection.to_write.put_nowait(result_message(msg['id'])) - def handle_unsubscribe_events(self, msg): - """Handle unsubscribe events command. - Async friendly. - """ - msg = UNSUBSCRIBE_EVENTS_MESSAGE_SCHEMA(msg) +@callback +def handle_unsubscribe_events(hass, connection, msg): + """Handle unsubscribe events command. - subscription = msg['subscription'] + Async friendly. + """ + subscription = msg['subscription'] - if subscription in self.event_listeners: - self.event_listeners.pop(subscription)() - self.to_write.put_nowait(result_message(msg['id'])) - else: - self.to_write.put_nowait(error_message( - msg['id'], ERR_NOT_FOUND, - 'Subscription not found.')) + if subscription in connection.event_listeners: + connection.event_listeners.pop(subscription)() + connection.to_write.put_nowait(result_message(msg['id'])) + else: + connection.to_write.put_nowait(error_message( + msg['id'], ERR_NOT_FOUND, 'Subscription not found.')) - def handle_call_service(self, msg): - """Handle call service command. - Async friendly. - """ - msg = CALL_SERVICE_MESSAGE_SCHEMA(msg) +@callback +def handle_call_service(hass, connection, msg): + """Handle call service command. - async def call_service_helper(msg): - """Call a service and fire complete message.""" - await self.hass.services.async_call( - msg['domain'], msg['service'], msg.get('service_data'), True) - self.send_message_outside(result_message(msg['id'])) + Async friendly. + """ + async def call_service_helper(msg): + """Call a service and fire complete message.""" + await hass.services.async_call( + msg['domain'], msg['service'], msg.get('service_data'), True) + connection.send_message_outside(result_message(msg['id'])) - self.hass.async_add_job(call_service_helper(msg)) + hass.async_add_job(call_service_helper(msg)) - def handle_get_states(self, msg): - """Handle get states command. - Async friendly. - """ - msg = GET_STATES_MESSAGE_SCHEMA(msg) +@callback +def handle_get_states(hass, connection, msg): + """Handle get states command. - self.to_write.put_nowait(result_message( - msg['id'], self.hass.states.async_all())) + Async friendly. + """ + connection.to_write.put_nowait(result_message( + msg['id'], hass.states.async_all())) - def handle_get_services(self, msg): - """Handle get services command. - Async friendly. - """ - msg = GET_SERVICES_MESSAGE_SCHEMA(msg) +@callback +def handle_get_services(hass, connection, msg): + """Handle get services command. - async def get_services_helper(msg): - """Get available services and fire complete message.""" - descriptions = await async_get_all_descriptions(self.hass) - self.send_message_outside(result_message(msg['id'], descriptions)) + Async friendly. + """ + async def get_services_helper(msg): + """Get available services and fire complete message.""" + descriptions = await async_get_all_descriptions(hass) + connection.send_message_outside( + result_message(msg['id'], descriptions)) - self.hass.async_add_job(get_services_helper(msg)) + hass.async_add_job(get_services_helper(msg)) - def handle_get_config(self, msg): - """Handle get config command. - Async friendly. - """ - msg = GET_CONFIG_MESSAGE_SCHEMA(msg) +@callback +def handle_get_config(hass, connection, msg): + """Handle get config command. - self.to_write.put_nowait(result_message( - msg['id'], self.hass.config.as_dict())) + Async friendly. + """ + connection.to_write.put_nowait(result_message( + msg['id'], hass.config.as_dict())) - def handle_get_panels(self, msg): - """Handle get panels command. - Async friendly. - """ - msg = GET_PANELS_MESSAGE_SCHEMA(msg) - panels = { - panel: - self.hass.data[frontend.DATA_PANELS][panel].to_response( - self.hass, self.request) - for panel in self.hass.data[frontend.DATA_PANELS]} +@callback +def handle_ping(hass, connection, msg): + """Handle ping command. - self.to_write.put_nowait(result_message( - msg['id'], panels)) - - def handle_ping(self, msg): - """Handle ping command. - - Async friendly. - """ - self.to_write.put_nowait(pong_message(msg['id'])) + Async friendly. + """ + connection.to_write.put_nowait(pong_message(msg['id'])) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index eab67c18aed..042943f7a3f 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -15,7 +15,7 @@ import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( - ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, CONF_EMAIL, CONF_PASSWORD, + ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, ATTR_NAME, CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON, __version__) from homeassistant.core import callback @@ -45,7 +45,6 @@ ATTR_ACCESS_TOKEN = 'access_token' ATTR_REFRESH_TOKEN = 'refresh_token' ATTR_CLIENT_ID = 'client_id' ATTR_CLIENT_SECRET = 'client_secret' -ATTR_NAME = 'name' ATTR_PAIRING_MODE = 'pairing_mode' ATTR_KIDDE_RADIO_CODE = 'kidde_radio_code' ATTR_HUB_NAME = 'hub_name' @@ -53,7 +52,8 @@ ATTR_HUB_NAME = 'hub_name' WINK_AUTH_CALLBACK_PATH = '/auth/wink/callback' WINK_AUTH_START = '/auth/wink' WINK_CONFIG_FILE = '.wink.conf' -USER_AGENT = "Manufacturer/Home-Assistant%s python/3 Wink/3" % __version__ +USER_AGENT = "Manufacturer/Home-Assistant{} python/3 Wink/3".format( + __version__) DEFAULT_CONFIG = { 'client_id': 'CLIENT_ID_HERE', diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 244605a7b97..2090f522709 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -23,7 +23,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow from homeassistant.util import slugify -REQUIREMENTS = ['PyXiaomiGateway==0.8.3'] +REQUIREMENTS = ['PyXiaomiGateway==0.9.5'] _LOGGER = logging.getLogger(__name__) @@ -36,6 +36,7 @@ CONF_DISCOVERY_RETRY = 'discovery_retry' CONF_GATEWAYS = 'gateways' CONF_INTERFACE = 'interface' CONF_KEY = 'key' +CONF_DISABLE = 'disable' DOMAIN = 'xiaomi_aqara' @@ -73,6 +74,7 @@ GATEWAY_CONFIG = vol.Schema({ vol.All(cv.string, vol.Length(min=16, max=16)), vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=9898): cv.port, + vol.Optional(CONF_DISABLE, default=False): cv.boolean, }) @@ -137,7 +139,8 @@ def setup(hass, config): xiaomi.listen() _LOGGER.debug("Gateways discovered. Listening for broadcasts") - for component in ['binary_sensor', 'sensor', 'switch', 'light', 'cover']: + for component in ['binary_sensor', 'sensor', 'switch', 'light', 'cover', + 'lock']: discovery.load_platform(hass, component, DOMAIN, {}, config) def stop_xiaomi(event): diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 39419034545..030e342847d 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -16,9 +16,9 @@ from homeassistant.helpers import discovery, entity from homeassistant.util import slugify REQUIREMENTS = [ - 'bellows==0.5.1', - 'zigpy==0.0.3', - 'zigpy-xbee==0.0.2', + 'bellows==0.6.0', + 'zigpy==0.1.0', + 'zigpy-xbee==0.1.1', ] DOMAIN = 'zha' @@ -151,6 +151,11 @@ class ApplicationListener: # Wait for device_initialized, instead pass + def raw_device_initialized(self, device): + """Handle a device initialization without quirks loaded.""" + # Wait for device_initialized, instead + pass + def device_initialized(self, device): """Handle device joined and basic information discovered.""" self._hass.async_add_job(self.async_device_initialized(device, True)) @@ -221,39 +226,73 @@ class ApplicationListener: self._config, ) - for cluster_id, cluster in endpoint.in_clusters.items(): - cluster_type = type(cluster) - if cluster_id in profile_clusters[0]: - continue - if cluster_type not in zha_const.SINGLE_CLUSTER_DEVICE_CLASS: - continue + for cluster in endpoint.in_clusters.values(): + await self._attempt_single_cluster_device( + endpoint, + cluster, + profile_clusters[0], + device_key, + zha_const.SINGLE_INPUT_CLUSTER_DEVICE_CLASS, + 'in_clusters', + discovered_info, + join, + ) - component = zha_const.SINGLE_CLUSTER_DEVICE_CLASS[cluster_type] - cluster_key = "{}-{}".format(device_key, cluster_id) - discovery_info = { - 'application_listener': self, - 'endpoint': endpoint, - 'in_clusters': {cluster.cluster_id: cluster}, - 'out_clusters': {}, - 'new_join': join, - 'unique_id': cluster_key, - 'entity_suffix': '_{}'.format(cluster_id), - } - discovery_info.update(discovered_info) - self._hass.data[DISCOVERY_KEY][cluster_key] = discovery_info - - await discovery.async_load_platform( - self._hass, - component, - DOMAIN, - {'discovery_key': cluster_key}, - self._config, + for cluster in endpoint.out_clusters.values(): + await self._attempt_single_cluster_device( + endpoint, + cluster, + profile_clusters[1], + device_key, + zha_const.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, + 'out_clusters', + discovered_info, + join, ) def register_entity(self, ieee, entity_obj): """Record the creation of a hass entity associated with ieee.""" self._device_registry[ieee].append(entity_obj) + async def _attempt_single_cluster_device(self, endpoint, cluster, + profile_clusters, device_key, + device_classes, discovery_attr, + entity_info, is_new_join): + """Try to set up an entity from a "bare" cluster.""" + if cluster.cluster_id in profile_clusters: + return + + component = None + for cluster_type, candidate_component in device_classes.items(): + if isinstance(cluster, cluster_type): + component = candidate_component + break + + if component is None: + return + + cluster_key = "{}-{}".format(device_key, cluster.cluster_id) + discovery_info = { + 'application_listener': self, + 'endpoint': endpoint, + 'in_clusters': {}, + 'out_clusters': {}, + 'new_join': is_new_join, + 'unique_id': cluster_key, + 'entity_suffix': '_{}'.format(cluster.cluster_id), + } + discovery_info[discovery_attr] = {cluster.cluster_id: cluster} + discovery_info.update(entity_info) + self._hass.data[DISCOVERY_KEY][cluster_key] = discovery_info + + await discovery.async_load_platform( + self._hass, + component, + DOMAIN, + {'discovery_key': cluster_key}, + self._config, + ) + class Entity(entity.Entity): """A base class for ZHA entities.""" @@ -287,18 +326,30 @@ class Entity(entity.Entity): kwargs.get('entity_suffix', ''), ) - for cluster in in_clusters.values(): - cluster.add_listener(self) - for cluster in out_clusters.values(): - cluster.add_listener(self) self._endpoint = endpoint self._in_clusters = in_clusters self._out_clusters = out_clusters - self._state = ha_const.STATE_UNKNOWN + self._state = None self._unique_id = unique_id + # Normally the entity itself is the listener. Sub-classes may set this + # to a dict of cluster ID -> listener to receive messages for specific + # clusters separately + self._in_listeners = {} + self._out_listeners = {} + application_listener.register_entity(ieee, self) + async def async_added_to_hass(self): + """Callback once the entity is added to hass. + + It is now safe to update the entity state + """ + for cluster_id, cluster in self._in_clusters.items(): + cluster.add_listener(self._in_listeners.get(cluster_id, self)) + for cluster_id, cluster in self._out_clusters.items(): + cluster.add_listener(self._out_listeners.get(cluster_id, self)) + @property def unique_id(self) -> str: """Return a unique ID.""" @@ -369,7 +420,7 @@ def get_discovery_info(hass, discovery_info): return all_discovery_info.get(discovery_key, None) -async def safe_read(cluster, attributes): +async def safe_read(cluster, attributes, allow_cache=True): """Swallow all exceptions from network read. If we throw during initialization, setup fails. Rather have an entity that @@ -379,7 +430,7 @@ async def safe_read(cluster, attributes): try: result, _ = await cluster.read_attributes( attributes, - allow_cache=False, + allow_cache=allow_cache, ) return result except Exception: # pylint: disable=broad-except diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index deaa1257396..37c7f5592a0 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -1,7 +1,8 @@ """All constants related to the ZHA component.""" DEVICE_CLASS = {} -SINGLE_CLUSTER_DEVICE_CLASS = {} +SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {} +SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {} COMPONENT_CLUSTERS = {} @@ -15,11 +16,17 @@ def populate_data(): from zigpy.profiles import PROFILES, zha, zll DEVICE_CLASS[zha.PROFILE_ID] = { + zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', + zha.DeviceType.LEVEL_CONTROL_SWITCH: 'binary_sensor', + zha.DeviceType.REMOTE_CONTROL: 'binary_sensor', zha.DeviceType.SMART_PLUG: 'switch', zha.DeviceType.ON_OFF_LIGHT: 'light', zha.DeviceType.DIMMABLE_LIGHT: 'light', zha.DeviceType.COLOR_DIMMABLE_LIGHT: 'light', + zha.DeviceType.ON_OFF_LIGHT_SWITCH: 'binary_sensor', + zha.DeviceType.DIMMER_SWITCH: 'binary_sensor', + zha.DeviceType.COLOR_DIMMER_SWITCH: 'binary_sensor', } DEVICE_CLASS[zll.PROFILE_ID] = { zll.DeviceType.ON_OFF_LIGHT: 'light', @@ -29,13 +36,26 @@ def populate_data(): zll.DeviceType.COLOR_LIGHT: 'light', zll.DeviceType.EXTENDED_COLOR_LIGHT: 'light', zll.DeviceType.COLOR_TEMPERATURE_LIGHT: 'light', + zll.DeviceType.COLOR_CONTROLLER: 'binary_sensor', + zll.DeviceType.COLOR_SCENE_CONTROLLER: 'binary_sensor', + zll.DeviceType.CONTROLLER: 'binary_sensor', + zll.DeviceType.SCENE_CONTROLLER: 'binary_sensor', + zll.DeviceType.ON_OFF_SENSOR: 'binary_sensor', } - SINGLE_CLUSTER_DEVICE_CLASS.update({ + SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ zcl.clusters.general.OnOff: 'switch', zcl.clusters.measurement.RelativeHumidity: 'sensor', zcl.clusters.measurement.TemperatureMeasurement: 'sensor', + zcl.clusters.measurement.PressureMeasurement: 'sensor', + zcl.clusters.measurement.IlluminanceMeasurement: 'sensor', + zcl.clusters.smartenergy.Metering: 'sensor', + zcl.clusters.homeautomation.ElectricalMeasurement: 'sensor', zcl.clusters.security.IasZone: 'binary_sensor', + zcl.clusters.hvac.Fan: 'fan', + }) + SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.update({ + zcl.clusters.general.OnOff: 'binary_sensor', }) # A map of hass components to all Zigbee clusters it could use diff --git a/homeassistant/components/zone/.translations/cy.json b/homeassistant/components/zone/.translations/cy.json new file mode 100644 index 00000000000..e34fae81b61 --- /dev/null +++ b/homeassistant/components/zone/.translations/cy.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Enw eisoes yn bodoli" + }, + "step": { + "init": { + "data": { + "icon": "Eicon", + "latitude": "Lledred", + "longitude": "Hydred", + "name": "Enw", + "passive": "Goddefol", + "radius": "Radiws" + }, + "title": "Ddiffinio paramedrau parth" + } + }, + "title": "Parth" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/de.json b/homeassistant/components/zone/.translations/de.json new file mode 100644 index 00000000000..fc1e3537f33 --- /dev/null +++ b/homeassistant/components/zone/.translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Name existiert bereits" + }, + "step": { + "init": { + "data": { + "icon": "Symbol", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad", + "name": "Name", + "passive": "Passiv", + "radius": "Radius" + }, + "title": "Definieren Sie die Zonenparameter" + } + }, + "title": "Zone" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/en.json b/homeassistant/components/zone/.translations/en.json new file mode 100644 index 00000000000..1faf0110a53 --- /dev/null +++ b/homeassistant/components/zone/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Name already exists" + }, + "step": { + "init": { + "data": { + "icon": "Icon", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name", + "passive": "Passive", + "radius": "Radius" + }, + "title": "Define zone parameters" + } + }, + "title": "Zone" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/ko.json b/homeassistant/components/zone/.translations/ko.json new file mode 100644 index 00000000000..364f8f3cc77 --- /dev/null +++ b/homeassistant/components/zone/.translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4" + }, + "step": { + "init": { + "data": { + "icon": "\uc544\uc774\ucf58", + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4", + "name": "\uc774\ub984", + "passive": "\uc790\ub3d9\ud654 \uc804\uc6a9", + "radius": "\ubc18\uacbd" + }, + "title": "\uad6c\uc5ed \ub9e4\uac1c \ubcc0\uc218 \uc815\uc758" + } + }, + "title": "\uad6c\uc5ed" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/lb.json b/homeassistant/components/zone/.translations/lb.json new file mode 100644 index 00000000000..10b65bcca30 --- /dev/null +++ b/homeassistant/components/zone/.translations/lb.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Numm g\u00ebtt et schonn" + }, + "step": { + "init": { + "data": { + "icon": "Ikone", + "latitude": "Breedegrad", + "longitude": "L\u00e4ngegrad", + "name": "Numm", + "passive": "Passif", + "radius": "Radius" + }, + "title": "D\u00e9fin\u00e9iert Zone Parameter" + } + }, + "title": "Zone" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/nl.json b/homeassistant/components/zone/.translations/nl.json new file mode 100644 index 00000000000..6dcf565ada6 --- /dev/null +++ b/homeassistant/components/zone/.translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Naam bestaat al" + }, + "step": { + "init": { + "data": { + "icon": "Pictogram", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "name": "Naam", + "passive": "Passief", + "radius": "Straal" + }, + "title": "Definieer zone parameters" + } + }, + "title": "Zone" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/no.json b/homeassistant/components/zone/.translations/no.json new file mode 100644 index 00000000000..3c1a91976f0 --- /dev/null +++ b/homeassistant/components/zone/.translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Navnet eksisterer allerede" + }, + "step": { + "init": { + "data": { + "icon": "Ikon", + "latitude": "Breddegrad", + "longitude": "Lengdegrad", + "name": "Navn", + "passive": "Passiv", + "radius": "Radius" + }, + "title": "Definer sone parametere" + } + }, + "title": "Sone" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/pl.json b/homeassistant/components/zone/.translations/pl.json new file mode 100644 index 00000000000..e649de4c75e --- /dev/null +++ b/homeassistant/components/zone/.translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Nazwa ju\u017c istnieje" + }, + "step": { + "init": { + "data": { + "icon": "Ikona", + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna", + "name": "Nazwa", + "passive": "Pasywnie", + "radius": "Promie\u0144" + }, + "title": "Zdefiniuj parametry strefy" + } + }, + "title": "Strefa" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/pt.json b/homeassistant/components/zone/.translations/pt.json new file mode 100644 index 00000000000..a4ced557805 --- /dev/null +++ b/homeassistant/components/zone/.translations/pt.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "Nome j\u00e1 existente" + }, + "step": { + "init": { + "data": { + "icon": "\u00cdcone", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nome", + "passive": "Passivo", + "radius": "Raio" + } + } + }, + "title": "Zona" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/ru.json b/homeassistant/components/zone/.translations/ru.json new file mode 100644 index 00000000000..f0619f2163c --- /dev/null +++ b/homeassistant/components/zone/.translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "\u0418\u043c\u044f \u0443\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442" + }, + "step": { + "init": { + "data": { + "icon": "\u0417\u043d\u0430\u0447\u043e\u043a", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "passive": "\u041f\u0430\u0441\u0441\u0438\u0432\u043d\u0430\u044f", + "radius": "\u0420\u0430\u0434\u0438\u0443\u0441" + }, + "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0437\u043e\u043d\u044b" + } + }, + "title": "\u0417\u043e\u043d\u0430" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/zh-Hans.json b/homeassistant/components/zone/.translations/zh-Hans.json new file mode 100644 index 00000000000..6d06b68dad8 --- /dev/null +++ b/homeassistant/components/zone/.translations/zh-Hans.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728" + }, + "step": { + "init": { + "data": { + "icon": "\u56fe\u6807", + "latitude": "\u7eac\u5ea6", + "longitude": "\u7ecf\u5ea6", + "name": "\u540d\u79f0", + "passive": "\u88ab\u52a8", + "radius": "\u534a\u5f84" + }, + "title": "\u5b9a\u4e49\u533a\u57df\u76f8\u5173\u53d8\u91cf" + } + }, + "title": "\u533a\u57df" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py new file mode 100644 index 00000000000..d3628fd57f3 --- /dev/null +++ b/homeassistant/components/zone/__init__.py @@ -0,0 +1,93 @@ +""" +Support for the definition of zones. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zone/ +""" + +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS) +from homeassistant.helpers import config_per_platform +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.util import slugify + +from .config_flow import configured_zones +from .const import CONF_PASSIVE, DOMAIN, HOME_ZONE +from .zone import Zone + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Unnamed zone' +DEFAULT_PASSIVE = False +DEFAULT_RADIUS = 100 + +ENTITY_ID_FORMAT = 'zone.{}' +ENTITY_ID_HOME = ENTITY_ID_FORMAT.format(HOME_ZONE) + +ICON_HOME = 'mdi:home' +ICON_IMPORT = 'mdi:import' + +# The config that zone accepts is the same as if it has platforms. +PLATFORM_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float), + vol.Optional(CONF_PASSIVE, default=DEFAULT_PASSIVE): cv.boolean, + vol.Optional(CONF_ICON): cv.icon, +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Setup configured zones as well as home assistant zone if necessary.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + zone_entries = configured_zones(hass) + for _, entry in config_per_platform(config, DOMAIN): + name = slugify(entry[CONF_NAME]) + if name not in zone_entries: + zone = Zone(hass, entry[CONF_NAME], entry[CONF_LATITUDE], + entry[CONF_LONGITUDE], entry.get(CONF_RADIUS), + entry.get(CONF_ICON), entry.get(CONF_PASSIVE)) + zone.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, entry[CONF_NAME], None, hass) + hass.async_add_job(zone.async_update_ha_state()) + hass.data[DOMAIN][name] = zone + + if HOME_ZONE not in hass.data[DOMAIN] and HOME_ZONE not in zone_entries: + name = hass.config.location_name + zone = Zone(hass, name, hass.config.latitude, hass.config.longitude, + DEFAULT_RADIUS, ICON_HOME, False) + zone.entity_id = ENTITY_ID_HOME + hass.async_add_job(zone.async_update_ha_state()) + hass.data[DOMAIN][slugify(name)] = zone + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up zone as config entry.""" + entry = config_entry.data + name = entry[CONF_NAME] + zone = Zone(hass, name, entry[CONF_LATITUDE], entry[CONF_LONGITUDE], + entry.get(CONF_RADIUS), entry.get(CONF_ICON), + entry.get(CONF_PASSIVE)) + zone.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, name, None, hass) + hass.async_add_job(zone.async_update_ha_state()) + hass.data[DOMAIN][slugify(name)] = zone + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + zones = hass.data[DOMAIN] + name = slugify(config_entry.data[CONF_NAME]) + zone = zones.pop(name) + await zone.async_remove() + return True diff --git a/homeassistant/components/zone/config_flow.py b/homeassistant/components/zone/config_flow.py new file mode 100644 index 00000000000..5ec955a48d9 --- /dev/null +++ b/homeassistant/components/zone/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow to configure zone component.""" + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries, data_entry_flow +from homeassistant.const import ( + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS) +from homeassistant.core import callback +from homeassistant.util import slugify + +from .const import CONF_PASSIVE, DOMAIN, HOME_ZONE + + +@callback +def configured_zones(hass): + """Return a set of the configured hosts.""" + return set((slugify(entry.data[CONF_NAME])) for + entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class ZoneFlowHandler(data_entry_flow.FlowHandler): + """Zone config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize zone configuration flow.""" + pass + + async def async_step_init(self, user_input=None): + """Handle a flow start.""" + errors = {} + + if user_input is not None: + name = slugify(user_input[CONF_NAME]) + if name not in configured_zones(self.hass) and name != HOME_ZONE: + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + errors['base'] = 'name_exists' + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({ + vol.Required(CONF_NAME): str, + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_RADIUS): vol.Coerce(float), + vol.Optional(CONF_ICON): str, + vol.Optional(CONF_PASSIVE): bool, + }), + errors=errors, + ) diff --git a/homeassistant/components/zone/const.py b/homeassistant/components/zone/const.py new file mode 100644 index 00000000000..b69ba67302a --- /dev/null +++ b/homeassistant/components/zone/const.py @@ -0,0 +1,5 @@ +"""Constants for the zone component.""" + +CONF_PASSIVE = 'passive' +DOMAIN = 'zone' +HOME_ZONE = 'home' diff --git a/homeassistant/components/zone/strings.json b/homeassistant/components/zone/strings.json new file mode 100644 index 00000000000..ff2c7c07c14 --- /dev/null +++ b/homeassistant/components/zone/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "Zone", + "step": { + "init": { + "title": "Define zone parameters", + "data": { + "name": "Name", + "latitude": "Latitude", + "longitude": "Longitude", + "radius": "Radius", + "passive": "Passive", + "icon": "Icon" + } + } + }, + "error": { + "name_exists": "Name already exists" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone/zone.py similarity index 57% rename from homeassistant/components/zone.py rename to homeassistant/components/zone/zone.py index b1a94f3809c..b7c2e9ee858 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone/zone.py @@ -1,54 +1,18 @@ -""" -Support for the definition of zones. +"""Component entity and functionality.""" -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/zone/ -""" -import asyncio -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, CONF_LATITUDE, - CONF_LONGITUDE, CONF_ICON, CONF_RADIUS) +from homeassistant.const import ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.helpers.entity import Entity from homeassistant.loader import bind_hass -from homeassistant.helpers import config_per_platform -from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.location import distance -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN ATTR_PASSIVE = 'passive' ATTR_RADIUS = 'radius' -CONF_PASSIVE = 'passive' - -DEFAULT_NAME = 'Unnamed zone' -DEFAULT_PASSIVE = False -DEFAULT_RADIUS = 100 -DOMAIN = 'zone' - -ENTITY_ID_FORMAT = 'zone.{}' -ENTITY_ID_HOME = ENTITY_ID_FORMAT.format('home') - -ICON_HOME = 'mdi:home' -ICON_IMPORT = 'mdi:import' - STATE = 'zoning' -# The config that zone accepts is the same as if it has platforms. -PLATFORM_SCHEMA = vol.Schema({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_LATITUDE): cv.latitude, - vol.Required(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float), - vol.Optional(CONF_PASSIVE, default=DEFAULT_PASSIVE): cv.boolean, - vol.Optional(CONF_ICON): cv.icon, -}, extra=vol.ALLOW_EXTRA) - @bind_hass def active_zone(hass, latitude, longitude, radius=0): @@ -104,32 +68,6 @@ def in_zone(zone, latitude, longitude, radius=0): return zone_dist - radius < zone.attributes[ATTR_RADIUS] -@asyncio.coroutine -def async_setup(hass, config): - """Set up the zone.""" - entities = set() - tasks = [] - for _, entry in config_per_platform(config, DOMAIN): - name = entry.get(CONF_NAME) - zone = Zone(hass, name, entry[CONF_LATITUDE], entry[CONF_LONGITUDE], - entry.get(CONF_RADIUS), entry.get(CONF_ICON), - entry.get(CONF_PASSIVE)) - zone.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, name, entities) - tasks.append(zone.async_update_ha_state()) - entities.add(zone.entity_id) - - if ENTITY_ID_HOME not in entities: - zone = Zone(hass, hass.config.location_name, - hass.config.latitude, hass.config.longitude, - DEFAULT_RADIUS, ICON_HOME, False) - zone.entity_id = ENTITY_ID_HOME - tasks.append(zone.async_update_ha_state()) - - yield from asyncio.wait(tasks, loop=hass.loop) - return True - - class Zone(Entity): """Representation of a Zone.""" diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index ad4ae66df17..a8ba5e4a6d3 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -11,15 +11,16 @@ from pprint import pprint import voluptuous as vol -from homeassistant.core import CoreState +from homeassistant.core import callback, CoreState from homeassistant.loader import get_platform from homeassistant.helpers import discovery from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.entity_values import EntityValues -from homeassistant.helpers.event import track_time_change +from homeassistant.helpers.event import async_track_time_change from homeassistant.util import convert import homeassistant.util.dt as dt_util import homeassistant.helpers.config_validation as cv @@ -31,7 +32,8 @@ from .const import DOMAIN, DATA_DEVICES, DATA_NETWORK, DATA_ENTITY_VALUES from .node_entity import ZWaveBaseEntity, ZWaveNodeEntity from . import workaround from .discovery_schemas import DISCOVERY_SCHEMAS -from .util import check_node_schema, check_value_schema, node_name +from .util import (check_node_schema, check_value_schema, node_name, + check_has_unique_id, is_node_parsed) REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.3'] @@ -182,10 +184,8 @@ def nice_print_node(node): node_dict['values'] = {value_id: _obj_to_dict(value) for value_id, value in node.values.items()} - print("\n\n\n") - print("FOUND NODE", node.product_name) - pprint(node_dict) - print("\n\n\n") + _LOGGER.info("FOUND NODE %s \n" + "%s", node.product_name, node_dict) def get_config_value(node, value_index, tries=5): @@ -203,8 +203,8 @@ def get_config_value(node, value_index, tries=5): return None -@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 Z-Wave platform (generic part).""" if discovery_info is None or DATA_NETWORK not in hass.data: return False @@ -219,7 +219,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # pylint: disable=R0914 -def setup(hass, config): +async def async_setup(hass, config): """Set up Z-Wave. Will automatically load components to support devices found on the network. @@ -287,7 +287,7 @@ def setup(hass, config): continue values = ZWaveDeviceEntityValues( - hass, schema, value, config, device_config) + hass, schema, value, config, device_config, registry) # We create a new list and update the reference here so that # the list can be safely iterated over in the main thread @@ -295,19 +295,43 @@ def setup(hass, config): hass.data[DATA_ENTITY_VALUES] = new_values component = EntityComponent(_LOGGER, DOMAIN, hass) + registry = await async_get_registry(hass) def node_added(node): """Handle a new node on the network.""" entity = ZWaveNodeEntity(node, network) - name = node_name(node) - generated_id = generate_entity_id(DOMAIN + '.{}', name, []) - node_config = device_config.get(generated_id) - if node_config.get(CONF_IGNORED): - _LOGGER.info( - "Ignoring node entity %s due to device settings", - generated_id) + + def _add_node_to_component(): + name = node_name(node) + generated_id = generate_entity_id(DOMAIN + '.{}', name, []) + node_config = device_config.get(generated_id) + if node_config.get(CONF_IGNORED): + _LOGGER.info( + "Ignoring node entity %s due to device settings", + generated_id) + return + component.add_entities([entity]) + + if entity.unique_id: + _add_node_to_component() return - component.add_entities([entity]) + + @callback + def _on_ready(sec): + _LOGGER.info("Z-Wave node %d ready after %d seconds", + entity.node_id, sec) + hass.async_add_job(_add_node_to_component) + + @callback + def _on_timeout(sec): + _LOGGER.warning( + "Z-Wave node %d not ready after %d seconds, " + "continuing anyway", + entity.node_id, sec) + hass.async_add_job(_add_node_to_component) + + hass.add_job(check_has_unique_id, entity, _on_ready, _on_timeout, + hass.loop) def network_ready(): """Handle the query of all awake nodes.""" @@ -361,6 +385,11 @@ def setup(hass, config): _LOGGER.info("Z-Wave soft_reset have been initialized") network.controller.soft_reset() + def update_config(service): + """Update the config from git.""" + _LOGGER.info("Configuration update has been initialized") + network.controller.update_ozw_config() + def test_network(service): """Test the network by sending commands to all the nodes.""" _LOGGER.info("Z-Wave test_network have been initialized") @@ -442,9 +471,16 @@ def setup(hass, config): if value.index != param: continue if value.type in [const.TYPE_LIST, const.TYPE_BOOL]: - value.data = selection - _LOGGER.info("Setting config list parameter %s on Node %s " - "with selection %s", param, node_id, + value.data = str(selection) + _LOGGER.info("Setting config parameter %s on Node %s " + "with list/bool selection %s", param, node_id, + str(selection)) + return + if value.type == const.TYPE_BUTTON: + network.manager.pressButton(value.value_id) + network.manager.releaseButton(value.value_id) + _LOGGER.info("Setting config parameter %s on Node %s " + "with button selection %s", param, node_id, selection) return value.data = int(selection) @@ -504,8 +540,7 @@ def setup(hass, config): "target node:%s, instance=%s", node_id, group, target_node_id, instance) - @asyncio.coroutine - def async_refresh_entity(service): + async def async_refresh_entity(service): """Refresh values that specific entity depends on.""" entity_id = service.data.get(ATTR_ENTITY_ID) async_dispatcher_send( @@ -559,8 +594,7 @@ def setup(hass, config): network.start() hass.bus.fire(const.EVENT_NETWORK_START) - @asyncio.coroutine - def _check_awaked(): + async def _check_awaked(): """Wait for Z-wave awaked state (or timeout) and finalize start.""" _LOGGER.debug( "network state: %d %s", network.state, @@ -585,7 +619,7 @@ def setup(hass, config): network.state_str) break else: - yield from asyncio.sleep(1, loop=hass.loop) + await asyncio.sleep(1, loop=hass.loop) hass.async_add_job(_finalize_start) @@ -613,6 +647,8 @@ def setup(hass, config): hass.services.register(DOMAIN, const.SERVICE_HEAL_NETWORK, heal_network) hass.services.register(DOMAIN, const.SERVICE_SOFT_RESET, soft_reset) + hass.services.register(DOMAIN, const.SERVICE_UPDATE_CONFIG, + update_config) hass.services.register(DOMAIN, const.SERVICE_TEST_NETWORK, test_network) hass.services.register(DOMAIN, const.SERVICE_STOP_NETWORK, @@ -668,9 +704,9 @@ def setup(hass, config): # Setup autoheal if autoheal: _LOGGER.info("Z-Wave network autoheal is enabled") - track_time_change(hass, heal_network, hour=0, minute=0, second=0) + async_track_time_change(hass, heal_network, hour=0, minute=0, second=0) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_zwave) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_zwave) return True @@ -679,7 +715,7 @@ class ZWaveDeviceEntityValues(): """Manages entity access to the underlying zwave value objects.""" def __init__(self, hass, schema, primary_value, zwave_config, - device_config): + device_config, registry): """Initialize the values object with the passed entity schema.""" self._hass = hass self._zwave_config = zwave_config @@ -688,6 +724,7 @@ class ZWaveDeviceEntityValues(): self._values = {} self._entity = None self._workaround_ignore = False + self._registry = registry for name in self._schema[const.DISC_VALUES].keys(): self._values[name] = None @@ -760,9 +797,13 @@ class ZWaveDeviceEntityValues(): workaround_component, component) component = workaround_component - value_name = _value_name(self.primary) - generated_id = generate_entity_id(component + '.{}', value_name, []) - node_config = self._device_config.get(generated_id) + entity_id = self._registry.async_get_entity_id( + component, DOMAIN, + compute_value_unique_id(self._node, self.primary)) + if entity_id is None: + value_name = _value_name(self.primary) + entity_id = generate_entity_id(component + '.{}', value_name, []) + node_config = self._device_config.get(entity_id) # Configure node _LOGGER.debug("Adding Node_id=%s Generic_command_class=%s, " @@ -775,7 +816,7 @@ class ZWaveDeviceEntityValues(): if node_config.get(CONF_IGNORED): _LOGGER.info( - "Ignoring entity %s due to device settings", generated_id) + "Ignoring entity %s due to device settings", entity_id) # No entity will be created for this value self._workaround_ignore = True return @@ -785,7 +826,7 @@ class ZWaveDeviceEntityValues(): if polling_intensity: self.primary.enable_poll(polling_intensity) - platform = get_platform(component, DOMAIN) + platform = get_platform(self._hass, component, DOMAIN) device = platform.get_device( node=self._node, values=self, node_config=node_config, hass=self._hass) @@ -798,14 +839,35 @@ class ZWaveDeviceEntityValues(): dict_id = id(self) - @asyncio.coroutine - def discover_device(component, device, dict_id): + @callback + def _on_ready(sec): + _LOGGER.info( + "Z-Wave entity %s (node_id: %d) ready after %d seconds", + device.name, self._node.node_id, sec) + self._hass.async_add_job(discover_device, component, device, + dict_id) + + @callback + def _on_timeout(sec): + _LOGGER.warning( + "Z-Wave entity %s (node_id: %d) not ready after %d seconds, " + "continuing anyway", + device.name, self._node.node_id, sec) + self._hass.async_add_job(discover_device, component, device, + dict_id) + + async def discover_device(component, device, dict_id): """Put device in a dictionary and call discovery on it.""" self._hass.data[DATA_DEVICES][dict_id] = device - yield from discovery.async_load_platform( + await discovery.async_load_platform( self._hass, component, DOMAIN, {const.DISCOVERY_DEVICE: dict_id}, self._zwave_config) - self._hass.add_job(discover_device, component, device, dict_id) + + if device.unique_id: + self._hass.add_job(discover_device, component, device, dict_id) + else: + self._hass.add_job(check_has_unique_id, device, _on_ready, + _on_timeout, self._hass.loop) class ZWaveDeviceEntity(ZWaveBaseEntity): @@ -822,8 +884,7 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): self.values.primary.set_change_verified(False) self._name = _value_name(self.values.primary) - self._unique_id = "{}-{}".format(self.node.node_id, - self.values.primary.object_id) + self._unique_id = self._compute_unique_id() self._update_attributes() dispatcher.connect( @@ -844,8 +905,7 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): self.update_properties() self.maybe_schedule_update() - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Add device to dict.""" async_dispatcher_connect( self.hass, @@ -855,6 +915,11 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): def _update_attributes(self): """Update the node attributes. May only be used inside callback.""" self.node_id = self.node.node_id + self._name = _value_name(self.values.primary) + if not self._unique_id: + self._unique_id = self._compute_unique_id() + if self._unique_id: + self.try_remove_and_add() if self.values.power: self.power_consumption = round( @@ -901,3 +966,15 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): for value in self.values: if value is not None: self.node.refresh_value(value.value_id) + + def _compute_unique_id(self): + if (is_node_parsed(self.node) and + self.values.primary.label != "Unknown") or \ + self.node.is_ready: + return compute_value_unique_id(self.node, self.values.primary) + return None + + +def compute_value_unique_id(node, value): + """Compute unique_id a value would get if it were to get one.""" + return "{}-{}".format(node.node_id, value.object_id) diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index bb4b33300e5..3e503e4d9a4 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -20,6 +20,7 @@ ATTR_POLL_INTENSITY = "poll_intensity" ATTR_VALUE_INDEX = "value_index" ATTR_VALUE_INSTANCE = "value_instance" NETWORK_READY_WAIT_SECS = 300 +NODE_READY_WAIT_SECS = 30 DISCOVERY_DEVICE = 'device' @@ -51,6 +52,7 @@ SERVICE_RENAME_VALUE = "rename_value" SERVICE_REFRESH_ENTITY = "refresh_entity" SERVICE_REFRESH_NODE = "refresh_node" SERVICE_RESET_NODE_METERS = "reset_node_meters" +SERVICE_UPDATE_CONFIG = "update_config" EVENT_SCENE_ACTIVATED = "zwave.scene_activated" EVENT_NODE_EVENT = "zwave.node_event" @@ -327,6 +329,7 @@ TYPE_DECIMAL = "Decimal" TYPE_INT = "Int" TYPE_LIST = "List" TYPE_STRING = "String" +TYPE_BUTTON = "Button" DISC_COMMAND_CLASS = "command_class" DISC_COMPONENT = "component" diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 5a4b1b02504..2c6d26802bd 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -9,7 +9,7 @@ from .const import ( ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_SCENE_DATA, ATTR_BASIC_LEVEL, EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, COMMAND_CLASS_CENTRAL_SCENE) -from .util import node_name +from .util import node_name, is_node_parsed _LOGGER = logging.getLogger(__name__) @@ -65,6 +65,15 @@ class ZWaveBaseEntity(Entity): self._update_scheduled = True self.hass.loop.call_later(0.1, do_update) + def try_remove_and_add(self): + """Remove this entity and add it back.""" + async def _async_remove_and_add(): + await self.async_remove() + self.entity_id = None + await self.platform.async_add_entities([self]) + if self.hass and self.platform: + self.hass.add_job(_async_remove_and_add) + class ZWaveNodeEntity(ZWaveBaseEntity): """Representation of a Z-Wave node.""" @@ -81,6 +90,7 @@ class ZWaveNodeEntity(ZWaveBaseEntity): self._name = node_name(self.node) self._product_name = node.product_name self._manufacturer_name = node.manufacturer_name + self._unique_id = self._compute_unique_id() self._attributes = {} self.wakeup_interval = None self.location = None @@ -95,6 +105,11 @@ class ZWaveNodeEntity(ZWaveBaseEntity): dispatcher.connect( self.network_scene_activated, ZWaveNetwork.SIGNAL_SCENE_EVENT) + @property + def unique_id(self): + """Unique ID of Z-wave node.""" + return self._unique_id + def network_node_changed(self, node=None, value=None, args=None): """Handle a changed node on the network.""" if node and node.node_id != self.node_id: @@ -138,8 +153,17 @@ class ZWaveNodeEntity(ZWaveBaseEntity): self.wakeup_interval = None self.battery_level = self.node.get_battery_level() + self._product_name = self.node.product_name + self._manufacturer_name = self.node.manufacturer_name + self._name = node_name(self.node) self._attributes = attributes + if not self._unique_id: + self._unique_id = self._compute_unique_id() + if self._unique_id: + # Node info parsed. Remove and re-add + self.try_remove_and_add() + self.maybe_schedule_update() def network_node_event(self, node, value): @@ -229,3 +253,8 @@ class ZWaveNodeEntity(ZWaveBaseEntity): attrs[ATTR_WAKEUP] = self.wakeup_interval return attrs + + def _compute_unique_id(self): + if is_node_parsed(self.node) or self.node.is_ready: + return 'node-{}'.format(self.node_id) + return None diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index 61855143d59..1762c33237d 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -119,6 +119,9 @@ set_wakeup: value: description: Value of the interval to set. (integer) +update_config: + description: Attempt to update ozw configuration files from git to support newer devices. + start_network: description: Start the Z-Wave network. This might take a while, depending on how big your Z-Wave network is. diff --git a/homeassistant/components/zwave/util.py b/homeassistant/components/zwave/util.py index 8c74b731ad6..b62eeb67d32 100644 --- a/homeassistant/components/zwave/util.py +++ b/homeassistant/components/zwave/util.py @@ -1,6 +1,9 @@ """Zwave util methods.""" +import asyncio import logging +import homeassistant.util.dt as dt_util + from . import const _LOGGER = logging.getLogger(__name__) @@ -65,5 +68,27 @@ def check_value_schema(value, schema): def node_name(node): """Return the name of the node.""" - return node.name or '{} {}'.format( - node.manufacturer_name, node.product_name) + if is_node_parsed(node): + return node.name or '{} {}'.format( + node.manufacturer_name, node.product_name) + return 'Unknown Node {}'.format(node.node_id) + + +async def check_has_unique_id(entity, ready_callback, timeout_callback, loop): + """Wait for entity to have unique_id.""" + start_time = dt_util.utcnow() + while True: + waited = int((dt_util.utcnow()-start_time).total_seconds()) + if entity.unique_id: + ready_callback(waited) + return + elif waited >= const.NODE_READY_WAIT_SECS: + # Wait up to NODE_READY_WAIT_SECS seconds for unique_id to appear. + timeout_callback(waited) + return + await asyncio.sleep(1, loop=loop) + + +def is_node_parsed(node): + """Check whether the node has been parsed or still waiting to be parsed.""" + return bool((node.manufacturer_name and node.product_name) or node.name) diff --git a/homeassistant/config.py b/homeassistant/config.py index e94fc297f48..44bf542f7cd 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1,5 +1,4 @@ """Module to help with parsing and generating configuration files.""" -import asyncio from collections import OrderedDict # pylint: disable=no-name-in-module from distutils.version import LooseVersion # pylint: disable=import-error @@ -7,19 +6,20 @@ import logging import os import re import shutil -import sys # pylint: disable=unused-import -from typing import Any, List, Tuple # NOQA +from typing import Any, List, Tuple, Optional # NOQA import voluptuous as vol from voluptuous.humanize import humanize_error +from homeassistant import auth from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ASSUMED_STATE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_PACKAGES, CONF_UNIT_SYSTEM, CONF_TIME_ZONE, CONF_ELEVATION, CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, TEMP_CELSIUS, __version__, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB, - CONF_WHITELIST_EXTERNAL_DIRS) + CONF_WHITELIST_EXTERNAL_DIRS, CONF_AUTH_PROVIDERS) from homeassistant.core import callback, DOMAIN as CONF_CORE from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import get_component, get_platform @@ -60,7 +60,7 @@ DEFAULT_CORE_CONFIG = ( (CONF_TIME_ZONE, 'UTC', 'time_zone', 'Pick yours from here: http://en.wiki' 'pedia.org/wiki/List_of_tz_database_time_zones'), (CONF_CUSTOMIZE, '!include customize.yaml', None, 'Customization file'), -) # type: Tuple[Tuple[str, Any, Any, str], ...] +) # type: Tuple[Tuple[str, Any, Any, Optional[str]], ...] DEFAULT_CONFIG = """ # Show links to resources in log and frontend introduction: @@ -131,13 +131,19 @@ PACKAGES_CONFIG_SCHEMA = vol.Schema({ {cv.slug: vol.Any(dict, list, None)}) # Only slugs for component names }) +CUSTOMIZE_DICT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_FRIENDLY_NAME): cv.string, + vol.Optional(ATTR_HIDDEN): cv.boolean, + vol.Optional(ATTR_ASSUMED_STATE): cv.boolean, +}, extra=vol.ALLOW_EXTRA) + CUSTOMIZE_CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_CUSTOMIZE, default={}): - vol.Schema({cv.entity_id: dict}), + vol.Schema({cv.entity_id: CUSTOMIZE_DICT_SCHEMA}), vol.Optional(CONF_CUSTOMIZE_DOMAIN, default={}): - vol.Schema({cv.string: dict}), + vol.Schema({cv.string: CUSTOMIZE_DICT_SCHEMA}), vol.Optional(CONF_CUSTOMIZE_GLOB, default={}): - vol.Schema({cv.string: OrderedDict}), + vol.Schema({cv.string: CUSTOMIZE_DICT_SCHEMA}), }) CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend({ @@ -152,6 +158,8 @@ CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend({ # pylint: disable=no-value-for-parameter vol.All(cv.ensure_list, [vol.IsDir()]), vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA, + vol.Optional(CONF_AUTH_PROVIDERS): + vol.All(cv.ensure_list, [auth.AUTH_PROVIDER_SCHEMA]) }) @@ -159,7 +167,7 @@ def get_default_config_dir() -> str: """Put together the default configuration directory based on the OS.""" data_dir = os.getenv('APPDATA') if os.name == "nt" \ else os.path.expanduser('~') - return os.path.join(data_dir, CONFIG_DIR_NAME) + return os.path.join(data_dir, CONFIG_DIR_NAME) # type: ignore def ensure_config_exists(config_dir: str, detect_location: bool = True) -> str: @@ -389,6 +397,12 @@ async def async_process_ha_core_config(hass, config): This method is a coroutine. """ config = CORE_CONFIG_SCHEMA(config) + + # Only load auth during startup. + if not hasattr(hass, 'auth'): + hass.auth = await auth.auth_manager_from_config( + hass, config.get(CONF_AUTH_PROVIDERS, [])) + hac = hass.config def set_time_zone(time_zone_str): @@ -534,7 +548,33 @@ def _identify_config_schema(module): return '', schema -def merge_packages_config(config, packages, _log_pkg_error=_log_pkg_error): +def _recursive_merge(pack_name, comp_name, config, conf, package): + """Merge package into conf, recursively.""" + for key, pack_conf in package.items(): + if isinstance(pack_conf, dict): + if not pack_conf: + continue + conf[key] = conf.get(key, OrderedDict()) + _recursive_merge(pack_name, comp_name, config, + conf=conf[key], package=pack_conf) + + elif isinstance(pack_conf, list): + if not pack_conf: + continue + conf[key] = cv.ensure_list(conf.get(key)) + conf[key].extend(cv.ensure_list(pack_conf)) + + else: + if conf.get(key) is not None: + _log_pkg_error( + pack_name, comp_name, config, + 'has keys that are defined multiple times') + else: + conf[key] = pack_conf + + +def merge_packages_config(hass, config, packages, + _log_pkg_error=_log_pkg_error): """Merge packages into the top-level configuration. Mutate config.""" # pylint: disable=too-many-nested-blocks PACKAGES_CONFIG_SCHEMA(packages) @@ -542,13 +582,15 @@ def merge_packages_config(config, packages, _log_pkg_error=_log_pkg_error): for comp_name, comp_conf in pack_conf.items(): if comp_name == CONF_CORE: continue - component = get_component(comp_name) + component = get_component(hass, comp_name) if component is None: _log_pkg_error(pack_name, comp_name, config, "does not exist") continue if hasattr(component, 'PLATFORM_SCHEMA'): + if not comp_conf: + continue # Ensure we dont add Falsy items to list config[comp_name] = cv.ensure_list(config.get(comp_name)) config[comp_name].extend(cv.ensure_list(comp_conf)) continue @@ -557,6 +599,8 @@ def merge_packages_config(config, packages, _log_pkg_error=_log_pkg_error): merge_type, _ = _identify_config_schema(component) if merge_type == 'list': + if not comp_conf: + continue # Ensure we dont add Falsy items to list config[comp_name] = cv.ensure_list(config.get(comp_name)) config[comp_name].extend(cv.ensure_list(comp_conf)) continue @@ -588,11 +632,10 @@ def merge_packages_config(config, packages, _log_pkg_error=_log_pkg_error): config[comp_name][key] = val continue - # The last merge type are sections that may occur only once + # The last merge type are sections that require recursive merging if comp_name in config: - _log_pkg_error( - pack_name, comp_name, config, "may occur only once" - " and it already exist in your main configuration") + _recursive_merge(pack_name, comp_name, config, + conf=config[comp_name], package=comp_conf) continue config[comp_name] = comp_conf @@ -607,7 +650,7 @@ def async_process_component_config(hass, config, domain): This method must be run in the event loop. """ - component = get_component(domain) + component = get_component(hass, domain) if hasattr(component, 'CONFIG_SCHEMA'): try: @@ -633,7 +676,7 @@ def async_process_component_config(hass, config, domain): platforms.append(p_validated) continue - platform = get_platform(domain, p_name) + platform = get_platform(hass, domain, p_name) if platform is None: continue @@ -665,22 +708,14 @@ async def async_check_ha_config_file(hass): This method is a coroutine. """ - proc = await asyncio.create_subprocess_exec( - sys.executable, '-m', 'homeassistant', '--script', - 'check_config', '--config', hass.config.config_dir, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, loop=hass.loop) + from homeassistant.scripts.check_config import check_ha_config_file - # Wait for the subprocess exit - log, _ = await proc.communicate() - exit_code = await proc.wait() + res = await hass.async_add_job( + check_ha_config_file, hass) - # Convert to ASCII - log = RE_ASCII.sub('', log.decode()) - - if exit_code != 0 or RE_YAML_ERROR.search(log): - return log - return None + if not res.errors: + return None + return '\n'.join([err.message for err in res.errors]) @callback diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index eb05e800683..8a73e424fb5 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -27,7 +27,7 @@ At a minimum, each config flow will have to define a version number and the 'init' step. @config_entries.HANDLERS.register(DOMAIN) - class ExampleConfigFlow(config_entries.ConfigFlowHandler): + class ExampleConfigFlow(config_entries.FlowHandler): VERSION = 1 @@ -115,6 +115,7 @@ import logging import os import uuid +from . import data_entry_flow from .core import callback from .exceptions import HomeAssistantError from .setup import async_setup_component, async_process_deps_reqs @@ -126,26 +127,24 @@ _LOGGER = logging.getLogger(__name__) HANDLERS = Registry() # Components that have config flows. In future we will auto-generate this list. FLOWS = [ - 'config_entry_example', + 'deconz', 'hue', + 'zone', ] -SOURCE_USER = 'user' -SOURCE_DISCOVERY = 'discovery' PATH_CONFIG = '.config_entries.json' SAVE_DELAY = 1 -RESULT_TYPE_FORM = 'form' -RESULT_TYPE_CREATE_ENTRY = 'create_entry' -RESULT_TYPE_ABORT = 'abort' - ENTRY_STATE_LOADED = 'loaded' ENTRY_STATE_SETUP_ERROR = 'setup_error' ENTRY_STATE_NOT_LOADED = 'not_loaded' ENTRY_STATE_FAILED_UNLOAD = 'failed_unload' +DISCOVERY_NOTIFICATION_ID = 'config_entry_discovery' +DISCOVERY_SOURCES = (data_entry_flow.SOURCE_DISCOVERY,) + class ConfigEntry: """Hold a configuration entry.""" @@ -187,24 +186,29 @@ class ConfigEntry: if not isinstance(result, bool): _LOGGER.error('%s.async_config_entry did not return boolean', - self.domain) + component.DOMAIN) result = False except Exception: # pylint: disable=broad-except _LOGGER.exception('Error setting up entry %s for %s', - self.title, self.domain) + self.title, component.DOMAIN) result = False + # Only store setup result as state if it was not forwarded. + if self.domain != component.DOMAIN: + return + if result: self.state = ENTRY_STATE_LOADED else: self.state = ENTRY_STATE_SETUP_ERROR - async def async_unload(self, hass): + async def async_unload(self, hass, *, component=None): """Unload an entry. Returns if unload is possible and was successful. """ - component = getattr(hass.components, self.domain) + if component is None: + component = getattr(hass.components, self.domain) supports_unload = hasattr(component, 'async_unload_entry') @@ -216,13 +220,13 @@ class ConfigEntry: if not isinstance(result, bool): _LOGGER.error('%s.async_unload_entry did not return boolean', - self.domain) + component.DOMAIN) result = False return result except Exception: # pylint: disable=broad-except _LOGGER.exception('Error unloading entry %s for %s', - self.title, self.domain) + self.title, component.DOMAIN) self.state = ENTRY_STATE_FAILED_UNLOAD return False @@ -246,18 +250,6 @@ class UnknownEntry(ConfigError): """Unknown entry specified.""" -class UnknownHandler(ConfigError): - """Unknown handler specified.""" - - -class UnknownFlow(ConfigError): - """Uknown flow specified.""" - - -class UnknownStep(ConfigError): - """Unknown step specified.""" - - class ConfigEntries: """Manage the configuration entries. @@ -267,7 +259,8 @@ class ConfigEntries: def __init__(self, hass, hass_config): """Initialize the entry manager.""" self.hass = hass - self.flow = FlowManager(hass, hass_config, self._async_add_entry) + self.flow = data_entry_flow.FlowManager( + hass, self._async_create_flow, self._async_finish_flow) self._hass_config = hass_config self._entries = None self._sched_save = None @@ -322,8 +315,54 @@ class ConfigEntries: entries = await self.hass.async_add_job(load_json, path) self._entries = [ConfigEntry(**entry) for entry in entries] - async def _async_add_entry(self, entry): - """Add an entry.""" + async def async_forward_entry_setup(self, entry, component): + """Forward the setup of an entry to a different component. + + By default an entry is setup with the component it belongs to. If that + component also has related platforms, the component will have to + forward the entry to be setup by that component. + + You don't want to await this coroutine if it is called as part of the + setup of a component, because it can cause a deadlock. + """ + # Setup Component if not set up yet + if component not in self.hass.config.components: + result = await async_setup_component( + self.hass, component, self._hass_config) + + if not result: + return False + + await entry.async_setup( + self.hass, component=getattr(self.hass.components, component)) + + async def async_forward_entry_unload(self, entry, component): + """Forward the unloading of an entry to a different component.""" + # It was never loaded. + if component not in self.hass.config.components: + return True + + return await entry.async_unload( + self.hass, component=getattr(self.hass.components, component)) + + async def _async_finish_flow(self, result): + """Finish a config flow and add an entry.""" + # If no discovery config entries in progress, remove notification. + if not any(ent['source'] in DISCOVERY_SOURCES for ent + in self.hass.config_entries.flow.async_progress()): + self.hass.components.persistent_notification.async_dismiss( + DISCOVERY_NOTIFICATION_ID) + + if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + return None + + entry = ConfigEntry( + version=result['version'], + domain=result['handler'], + title=result['title'], + data=result['data'], + source=result['source'], + ) self._entries.append(entry) self._async_schedule_save() @@ -336,6 +375,38 @@ class ConfigEntries: await async_setup_component( self.hass, entry.domain, self._hass_config) + # Return Entry if they not from a discovery request + if result['source'] not in DISCOVERY_SOURCES: + return entry + + return entry + + async def _async_create_flow(self, handler, *, source, data): + """Create a flow for specified handler. + + Handler key is the domain of the component that we want to setup. + """ + component = getattr(self.hass.components, handler) + handler = HANDLERS.get(handler) + + if handler is None: + raise data_entry_flow.UnknownHandler + + # Make sure requirements and dependencies of component are resolved + await async_process_deps_reqs( + self.hass, self._hass_config, handler, component) + + # Create notification. + if source in DISCOVERY_SOURCES: + self.hass.components.persistent_notification.async_create( + title='New devices discovered', + message=("We have discovered new devices on your network. " + "[Check it out](/config/integrations)"), + notification_id=DISCOVERY_NOTIFICATION_ID + ) + + return handler() + @callback def _async_schedule_save(self): """Schedule saving the entity registry.""" @@ -353,157 +424,3 @@ class ConfigEntries: await self.hass.async_add_job( save_json, self.hass.config.path(PATH_CONFIG), data) - - -class FlowManager: - """Manage all the config flows that are in progress.""" - - def __init__(self, hass, hass_config, async_add_entry): - """Initialize the flow manager.""" - self.hass = hass - self._hass_config = hass_config - self._progress = {} - self._async_add_entry = async_add_entry - - @callback - def async_progress(self): - """Return the flows in progress.""" - return [{ - 'flow_id': flow.flow_id, - 'domain': flow.domain, - 'source': flow.source, - } for flow in self._progress.values()] - - async def async_init(self, domain, *, source=SOURCE_USER, data=None): - """Start a configuration flow.""" - handler = HANDLERS.get(domain) - - if handler is None: - # This will load the component and thus register the handler - component = getattr(self.hass.components, domain) - handler = HANDLERS.get(domain) - - if handler is None: - raise self.hass.helpers.UnknownHandler - - # Make sure requirements and dependencies of component are resolved - await async_process_deps_reqs( - self.hass, self._hass_config, domain, component) - - flow_id = uuid.uuid4().hex - flow = self._progress[flow_id] = handler() - flow.hass = self.hass - flow.domain = domain - flow.flow_id = flow_id - flow.source = source - - if source == SOURCE_USER: - step = 'init' - else: - step = source - - return await self._async_handle_step(flow, step, data) - - async def async_configure(self, flow_id, user_input=None): - """Start or continue a configuration flow.""" - flow = self._progress.get(flow_id) - - if flow is None: - raise UnknownFlow - - step_id, data_schema = flow.cur_step - - if data_schema is not None and user_input is not None: - user_input = data_schema(user_input) - - return await self._async_handle_step( - flow, step_id, user_input) - - @callback - def async_abort(self, flow_id): - """Abort a flow.""" - if self._progress.pop(flow_id, None) is None: - raise UnknownFlow - - async def _async_handle_step(self, flow, step_id, user_input): - """Handle a step of a flow.""" - method = "async_step_{}".format(step_id) - - if not hasattr(flow, method): - self._progress.pop(flow.flow_id) - raise UnknownStep("Handler {} doesn't support step {}".format( - flow.__class__.__name__, step_id)) - - result = await getattr(flow, method)(user_input) - - if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_ABORT): - raise ValueError( - 'Handler returned incorrect type: {}'.format(result['type'])) - - if result['type'] == RESULT_TYPE_FORM: - flow.cur_step = (result['step_id'], result['data_schema']) - return result - - # Abort and Success results both finish the flow - self._progress.pop(flow.flow_id) - - if result['type'] == RESULT_TYPE_ABORT: - return result - - entry = ConfigEntry( - version=flow.VERSION, - domain=flow.domain, - title=result['title'], - data=result.pop('data'), - source=flow.source - ) - await self._async_add_entry(entry) - return result - - -class ConfigFlowHandler: - """Handle the configuration flow of a component.""" - - # Set by flow manager - flow_id = None - hass = None - domain = None - source = SOURCE_USER - cur_step = None - - # Set by dev - # VERSION - - @callback - def async_show_form(self, *, step_id, data_schema=None, errors=None): - """Return the definition of a form to gather user input.""" - return { - 'type': RESULT_TYPE_FORM, - 'flow_id': self.flow_id, - 'domain': self.domain, - 'step_id': step_id, - 'data_schema': data_schema, - 'errors': errors, - } - - @callback - def async_create_entry(self, *, title, data): - """Finish config flow and create a config entry.""" - return { - 'type': RESULT_TYPE_CREATE_ENTRY, - 'flow_id': self.flow_id, - 'domain': self.domain, - 'title': title, - 'data': data, - } - - @callback - def async_abort(self, *, reason): - """Abort the config flow.""" - return { - 'type': RESULT_TYPE_ABORT, - 'flow_id': self.flow_id, - 'domain': self.domain, - 'reason': reason - } diff --git a/homeassistant/const.py b/homeassistant/const.py index 4ce2f503ad6..5644c3d0a1f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 66 +MINOR_VERSION = 72 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) @@ -30,6 +30,7 @@ CONF_API_KEY = 'api_key' CONF_API_VERSION = 'api_version' CONF_AT = 'at' CONF_AUTHENTICATION = 'authentication' +CONF_AUTH_PROVIDERS = 'auth_providers' CONF_BASE = 'base' CONF_BEFORE = 'before' CONF_BELOW = 'below' @@ -165,6 +166,12 @@ EVENT_SERVICE_REMOVED = 'service_removed' EVENT_LOGBOOK_ENTRY = 'logbook_entry' EVENT_THEMES_UPDATED = 'themes_updated' +# #### DEVICE CLASSES #### +DEVICE_CLASS_BATTERY = 'battery' +DEVICE_CLASS_HUMIDITY = 'humidity' +DEVICE_CLASS_ILLUMINANCE = 'illuminance' +DEVICE_CLASS_TEMPERATURE = 'temperature' + # #### STATES #### STATE_ON = 'on' STATE_OFF = 'off' @@ -214,6 +221,9 @@ ATTR_SERVICE_DATA = 'service_data' # IDs ATTR_ID = 'id' +# Name +ATTR_NAME = 'name' + # Data for a SERVICE_EXECUTED event ATTR_SERVICE_CALL_ID = 'service_call_id' diff --git a/homeassistant/core.py b/homeassistant/core.py index a486ee1adbf..bc3b598180c 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -17,7 +17,7 @@ import threading from time import monotonic from types import MappingProxyType -from typing import Optional, Any, Callable, List # NOQA +from typing import Optional, Any, Callable, List, TypeVar, Dict # NOQA from async_timeout import timeout import voluptuous as vol @@ -41,6 +41,8 @@ import homeassistant.util.dt as dt_util import homeassistant.util.location as location from homeassistant.util.unit_system import UnitSystem, METRIC_SYSTEM # NOQA +T = TypeVar('T') + DOMAIN = 'homeassistant' # How long we wait for the result of a service call @@ -70,16 +72,15 @@ def valid_state(state: str) -> bool: return len(state) < 256 -def callback(func: Callable[..., None]) -> Callable[..., None]: +def callback(func: Callable[..., T]) -> Callable[..., T]: """Annotation to mark method as safe to call from within the event loop.""" - # pylint: disable=protected-access - func._hass_callback = True + setattr(func, '_hass_callback', True) return func def is_callback(func: Callable[..., Any]) -> bool: """Check if function is safe to be called in the event loop.""" - return '_hass_callback' in func.__dict__ + return getattr(func, '_hass_callback', False) is True @callback @@ -117,11 +118,7 @@ class HomeAssistant(object): else: self.loop = loop or asyncio.get_event_loop() - executor_opts = {'max_workers': 10} - if sys.version_info[:2] >= (3, 5): - # It will default set to the number of processors on the machine, - # multiplied by 5. That is better for overlap I/O workers. - executor_opts['max_workers'] = None + executor_opts = {'max_workers': None} if sys.version_info[:2] >= (3, 6): executor_opts['thread_name_prefix'] = 'SyncWorker' @@ -140,13 +137,14 @@ class HomeAssistant(object): self.data = {} self.state = CoreState.not_running self.exit_code = None + self.config_entries = None @property def is_running(self) -> bool: """Return if Home Assistant is running.""" return self.state in (CoreState.starting, CoreState.running) - def start(self) -> None: + def start(self) -> int: """Start home assistant.""" # Register the async start fire_coroutine_threadsafe(self.async_start(), self.loop) @@ -156,13 +154,13 @@ class HomeAssistant(object): # Block until stopped _LOGGER.info("Starting Home Assistant core loop") self.loop.run_forever() - return self.exit_code except KeyboardInterrupt: self.loop.call_soon_threadsafe( self.loop.create_task, self.async_stop()) self.loop.run_forever() finally: self.loop.close() + return self.exit_code async def async_start(self): """Finalize startup from inside the event loop. @@ -204,7 +202,10 @@ class HomeAssistant(object): self.loop.call_soon_threadsafe(self.async_add_job, target, *args) @callback - def async_add_job(self, target: Callable[..., None], *args: Any) -> None: + def async_add_job( + self, + target: Callable[..., Any], + *args: Any) -> Optional[asyncio.tasks.Task]: """Add a job from within the eventloop. This method must be run in the event loop. @@ -358,7 +359,7 @@ class EventBus(object): def __init__(self, hass: HomeAssistant) -> None: """Initialize a new event bus.""" - self._listeners = {} + self._listeners = {} # type: Dict[str, List[Callable]] self._hass = hass @callback @@ -1043,7 +1044,7 @@ class Config(object): # List of allowed external dirs to access self.whitelist_external_dirs = set() - def distance(self: object, lat: float, lon: float) -> float: + def distance(self, lat: float, lon: float) -> float: """Calculate distance from Home Assistant. Async friendly. @@ -1064,15 +1065,19 @@ class Config(object): """Check if the path is valid for access from outside.""" assert path is not None - parent = pathlib.Path(path) + thepath = pathlib.Path(path) try: - parent = parent.resolve() # pylint: disable=no-member + # The file path does not have to exist (it's parent should) + if thepath.exists(): + thepath = thepath.resolve() + else: + thepath = thepath.parent.resolve() except (FileNotFoundError, RuntimeError, PermissionError): return False for whitelisted_path in self.whitelist_external_dirs: try: - parent.relative_to(whitelisted_path) + thepath.relative_to(whitelisted_path) return True except ValueError: pass diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py new file mode 100644 index 00000000000..5095297e795 --- /dev/null +++ b/homeassistant/data_entry_flow.py @@ -0,0 +1,167 @@ +"""Classes to help gather user submissions.""" +import logging +import uuid + +from .core import callback +from .exceptions import HomeAssistantError + +_LOGGER = logging.getLogger(__name__) + +SOURCE_USER = 'user' +SOURCE_DISCOVERY = 'discovery' + +RESULT_TYPE_FORM = 'form' +RESULT_TYPE_CREATE_ENTRY = 'create_entry' +RESULT_TYPE_ABORT = 'abort' + + +class FlowError(HomeAssistantError): + """Error while configuring an account.""" + + +class UnknownHandler(FlowError): + """Unknown handler specified.""" + + +class UnknownFlow(FlowError): + """Uknown flow specified.""" + + +class UnknownStep(FlowError): + """Unknown step specified.""" + + +class FlowManager: + """Manage all the flows that are in progress.""" + + def __init__(self, hass, async_create_flow, async_finish_flow): + """Initialize the flow manager.""" + self.hass = hass + self._progress = {} + self._async_create_flow = async_create_flow + self._async_finish_flow = async_finish_flow + + @callback + def async_progress(self): + """Return the flows in progress.""" + return [{ + 'flow_id': flow.flow_id, + 'handler': flow.handler, + 'source': flow.source, + } for flow in self._progress.values()] + + async def async_init(self, handler, *, source=SOURCE_USER, data=None): + """Start a configuration flow.""" + flow = await self._async_create_flow(handler, source=source, data=data) + flow.hass = self.hass + flow.handler = handler + flow.flow_id = uuid.uuid4().hex + flow.source = source + self._progress[flow.flow_id] = flow + + if source == SOURCE_USER: + step = 'init' + else: + step = source + + return await self._async_handle_step(flow, step, data) + + async def async_configure(self, flow_id, user_input=None): + """Continue a configuration flow.""" + flow = self._progress.get(flow_id) + + if flow is None: + raise UnknownFlow + + step_id, data_schema = flow.cur_step + + if data_schema is not None and user_input is not None: + user_input = data_schema(user_input) + + return await self._async_handle_step( + flow, step_id, user_input) + + @callback + def async_abort(self, flow_id): + """Abort a flow.""" + if self._progress.pop(flow_id, None) is None: + raise UnknownFlow + + async def _async_handle_step(self, flow, step_id, user_input): + """Handle a step of a flow.""" + method = "async_step_{}".format(step_id) + + if not hasattr(flow, method): + self._progress.pop(flow.flow_id) + raise UnknownStep("Handler {} doesn't support step {}".format( + flow.__class__.__name__, step_id)) + + result = await getattr(flow, method)(user_input) + + if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_ABORT): + raise ValueError( + 'Handler returned incorrect type: {}'.format(result['type'])) + + if result['type'] == RESULT_TYPE_FORM: + flow.cur_step = (result['step_id'], result['data_schema']) + return result + + # Abort and Success results both finish the flow + self._progress.pop(flow.flow_id) + + # We pass a copy of the result because we're mutating our version + entry = await self._async_finish_flow(dict(result)) + + if result['type'] == RESULT_TYPE_CREATE_ENTRY: + result['result'] = entry + return result + + +class FlowHandler: + """Handle the configuration flow of a component.""" + + # Set by flow manager + flow_id = None + hass = None + handler = None + source = SOURCE_USER + cur_step = None + + # Set by developer + VERSION = 1 + + @callback + def async_show_form(self, *, step_id, data_schema=None, errors=None): + """Return the definition of a form to gather user input.""" + return { + 'type': RESULT_TYPE_FORM, + 'flow_id': self.flow_id, + 'handler': self.handler, + 'step_id': step_id, + 'data_schema': data_schema, + 'errors': errors, + } + + @callback + def async_create_entry(self, *, title, data): + """Finish config flow and create a config entry.""" + return { + 'version': self.VERSION, + 'type': RESULT_TYPE_CREATE_ENTRY, + 'flow_id': self.flow_id, + 'handler': self.handler, + 'title': title, + 'data': data, + 'source': self.source, + } + + @callback + def async_abort(self, *, reason): + """Abort the config flow.""" + return { + 'type': RESULT_TYPE_ABORT, + 'flow_id': self.flow_id, + 'handler': self.handler, + 'reason': reason + } diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index cb8a3c87820..73bd2377950 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -1,4 +1,5 @@ """The exceptions used by Home Assistant.""" +import jinja2 class HomeAssistantError(Exception): @@ -22,7 +23,7 @@ class NoEntitySpecifiedError(HomeAssistantError): class TemplateError(HomeAssistantError): """Error during template rendering.""" - def __init__(self, exception): + def __init__(self, exception: jinja2.TemplateError) -> None: """Init the error.""" super().__init__('{}: {}'.format(exception.__class__.__name__, exception)) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 72f2214b5e7..bb34942ad79 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -149,34 +149,27 @@ def _async_get_connector(hass, verify_ssl=True): This method must be run in the event loop. """ - is_new = False + key = DATA_CONNECTOR if verify_ssl else DATA_CONNECTOR_NOTVERIFY + + if key in hass.data: + return hass.data[key] if verify_ssl: - if DATA_CONNECTOR not in hass.data: - ssl_context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) - ssl_context.load_verify_locations(cafile=certifi.where(), - capath=None) - connector = aiohttp.TCPConnector(loop=hass.loop, - ssl_context=ssl_context) - hass.data[DATA_CONNECTOR] = connector - is_new = True - else: - connector = hass.data[DATA_CONNECTOR] + ssl_context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) + ssl_context.load_verify_locations(cafile=certifi.where(), + capath=None) else: - if DATA_CONNECTOR_NOTVERIFY not in hass.data: - connector = aiohttp.TCPConnector(loop=hass.loop, verify_ssl=False) - hass.data[DATA_CONNECTOR_NOTVERIFY] = connector - is_new = True - else: - connector = hass.data[DATA_CONNECTOR_NOTVERIFY] + ssl_context = False - if is_new: - @callback - def _async_close_connector(event): - """Close connector pool.""" - connector.close() + connector = aiohttp.TCPConnector(loop=hass.loop, ssl=ssl_context) + hass.data[key] = connector - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_CLOSE, _async_close_connector) + @callback + def _async_close_connector(event): + """Close connector pool.""" + connector.close() + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_CLOSE, _async_close_connector) return connector diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index f8f841cc449..cb577e8a9c7 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -393,8 +393,8 @@ def zone(hass, zone_ent, entity): if latitude is None or longitude is None: return False - return zone_cmp.in_zone(zone_ent, latitude, longitude, - entity.attributes.get(ATTR_GPS_ACCURACY, 0)) + return zone_cmp.zone.in_zone(zone_ent, latitude, longitude, + entity.attributes.get(ATTR_GPS_ACCURACY, 0)) def zone_from_config(config, config_validation=True): diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 4b7c58f6e66..0bd490940a9 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -12,7 +12,6 @@ from typing import Any, Union, TypeVar, Callable, Sequence, Dict import voluptuous as vol -from homeassistant.loader import get_platform from homeassistant.const import ( CONF_PLATFORM, CONF_SCAN_INTERVAL, TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_ALIAS, CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, WEEKDAYS, @@ -97,6 +96,36 @@ def isdevice(value): raise vol.Invalid('No device at {} found'.format(value)) +def matches_regex(regex): + """Validate that the value is a string that matches a regex.""" + regex = re.compile(regex) + + def validator(value: Any) -> str: + """Validate that value matches the given regex.""" + if not isinstance(value, str): + raise vol.Invalid('not a string value: {}'.format(value)) + + if not regex.match(value): + raise vol.Invalid('value {} does not match regular expression {}' + .format(regex.pattern, value)) + + return value + return validator + + +def is_regex(value): + """Validate that a string is a valid regular expression.""" + try: + r = re.compile(value) + return r + except TypeError: + raise vol.Invalid("value {} is of the wrong type for a regular " + "expression".format(value)) + except re.error: + raise vol.Invalid("value {} is not a valid regular expression".format( + value)) + + def isfile(value: Any) -> str: """Validate that the value is an existing file.""" if value is None: @@ -283,19 +312,6 @@ def match_all(value): return value -def platform_validator(domain): - """Validate if platform exists for given domain.""" - def validator(value): - """Test if platform exists.""" - if value is None: - raise vol.Invalid('platform cannot be None') - if get_platform(domain, str(value)): - return value - raise vol.Invalid( - 'platform {} does not exist for {}'.format(value, domain)) - return validator - - def positive_timedelta(value: timedelta) -> timedelta: """Validate timedelta is positive.""" if value < timedelta(0): diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py new file mode 100644 index 00000000000..5a0b2ca56ea --- /dev/null +++ b/homeassistant/helpers/data_entry_flow.py @@ -0,0 +1,102 @@ +"""Helpers for the data entry flow.""" + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator + + +class _BaseFlowManagerView(HomeAssistantView): + """Foundation for flow manager views.""" + + def __init__(self, flow_mgr): + """Initialize the flow manager index view.""" + self._flow_mgr = flow_mgr + + # pylint: disable=no-self-use + def _prepare_result_json(self, result): + """Convert result to JSON.""" + if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + data = result.copy() + data.pop('result') + data.pop('data') + return data + + elif result['type'] != data_entry_flow.RESULT_TYPE_FORM: + return result + + import voluptuous_serialize + + data = result.copy() + + schema = data['data_schema'] + if schema is None: + data['data_schema'] = [] + else: + data['data_schema'] = voluptuous_serialize.convert(schema) + + return data + + +class FlowManagerIndexView(_BaseFlowManagerView): + """View to create config flows.""" + + @RequestDataValidator(vol.Schema({ + vol.Required('handler'): vol.Any(str, list), + }, extra=vol.ALLOW_EXTRA)) + async def post(self, request, data): + """Handle a POST request.""" + if isinstance(data['handler'], list): + handler = tuple(data['handler']) + else: + handler = data['handler'] + + try: + result = await self._flow_mgr.async_init(handler) + except data_entry_flow.UnknownHandler: + return self.json_message('Invalid handler specified', 404) + except data_entry_flow.UnknownStep: + return self.json_message('Handler does not support init', 400) + + result = self._prepare_result_json(result) + + return self.json(result) + + +class FlowManagerResourceView(_BaseFlowManagerView): + """View to interact with the flow manager.""" + + async def get(self, request, flow_id): + """Get the current state of a data_entry_flow.""" + try: + result = await self._flow_mgr.async_configure(flow_id) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + result = self._prepare_result_json(result) + + return self.json(result) + + @RequestDataValidator(vol.Schema(dict), allow_empty=True) + async def post(self, request, flow_id, data): + """Handle a POST 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) + + result = self._prepare_result_json(result) + + return self.json(result) + + async def delete(self, request, flow_id): + """Cancel a flow in progress.""" + try: + self._flow_mgr.async_abort(flow_id) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + return self.json_message('Flow aborted') diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 82322fec1e5..cb587c432c1 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -5,8 +5,6 @@ There are two different types of discoveries that can be fired/listened for. - listen_platform/discover_platform is for platforms. These are used by components to allow discovery of their platforms. """ -import asyncio - from homeassistant import setup, core from homeassistant.loader import bind_hass from homeassistant.const import ( @@ -58,17 +56,16 @@ def discover(hass, service, discovered=None, component=None, hass_config=None): async_discover(hass, service, discovered, component, hass_config)) -@asyncio.coroutine @bind_hass -def async_discover(hass, service, discovered=None, component=None, - hass_config=None): +async def async_discover(hass, service, discovered=None, component=None, + hass_config=None): """Fire discovery event. Can ensure a component is loaded.""" if component in DEPENDENCY_BLACKLIST: raise HomeAssistantError( 'Cannot discover the {} component.'.format(component)) if component is not None and component not in hass.config.components: - yield from setup.async_setup_component( + await setup.async_setup_component( hass, component, hass_config) data = { @@ -134,10 +131,9 @@ def load_platform(hass, component, platform, discovered=None, hass_config)) -@asyncio.coroutine @bind_hass -def async_load_platform(hass, component, platform, discovered=None, - hass_config=None): +async def async_load_platform(hass, component, platform, discovered=None, + hass_config=None): """Load a component and platform dynamically. Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be @@ -148,7 +144,7 @@ def async_load_platform(hass, component, platform, discovered=None, Use `listen_platform` to register a callback for these events. - Warning: Do not yield from this inside a setup method to avoid a dead lock. + Warning: Do not await this inside a setup method to avoid a dead lock. Use `hass.async_add_job(async_load_platform(..))` instead. This method is a coroutine. @@ -160,7 +156,7 @@ def async_load_platform(hass, component, platform, discovered=None, setup_success = True if component not in hass.config.components: - setup_success = yield from setup.async_setup_component( + setup_success = await setup.async_setup_component( hass, component, hass_config) # No need to fire event if we could not setup component diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 4efe8d2f6c3..efaefc26184 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -4,7 +4,7 @@ import logging import functools as ft from timeit import default_timer as timer -from typing import Optional, List +from typing import Optional, List, Iterable from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ICON, @@ -42,7 +42,7 @@ def generate_entity_id(entity_id_format: str, name: Optional[str], @callback def async_generate_entity_id(entity_id_format: str, name: Optional[str], - current_ids: Optional[List[str]] = None, + current_ids: Optional[Iterable[str]] = None, hass: Optional[HomeAssistant] = None) -> str: """Generate a unique entity ID based on given entity IDs or used IDs.""" if current_ids is None: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index f086437c10d..c82ae2a46f0 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -40,16 +40,7 @@ class EntityComponent(object): self.config = None self._platforms = { - domain: EntityPlatform( - hass=hass, - logger=logger, - domain=domain, - platform_name=domain, - scan_interval=self.scan_interval, - parallel_updates=0, - entity_namespace=None, - async_entities_added_callback=self._async_update_group, - ) + domain: self._async_init_entity_platform(domain, None) } self.async_add_entities = self._platforms[domain].async_add_entities self.add_entities = self._platforms[domain].add_entities @@ -102,6 +93,38 @@ class EntityComponent(object): discovery.async_listen_platform( self.hass, self.domain, component_platform_discovered) + async def async_setup_entry(self, config_entry): + """Setup a config entry.""" + platform_type = config_entry.domain + platform = await async_prepare_setup_platform( + self.hass, self.config, self.domain, platform_type) + + if platform is None: + return False + + key = config_entry.entry_id + + if key in self._platforms: + raise ValueError('Config entry has already been setup!') + + self._platforms[key] = self._async_init_entity_platform( + platform_type, platform + ) + + return await self._platforms[key].async_setup_entry(config_entry) + + async def async_unload_entry(self, config_entry): + """Unload a config entry.""" + key = config_entry.entry_id + + platform = self._platforms.pop(key, None) + + if platform is None: + raise ValueError('Config entry was never loaded!') + + await platform.async_reset() + return True + @callback def async_extract_from_service(self, service, expand_group=True): """Extract all known and available entities from a service call. @@ -127,34 +150,19 @@ class EntityComponent(object): if platform is None: return - # Config > Platform > Component - scan_interval = ( - platform_config.get(CONF_SCAN_INTERVAL) or - getattr(platform, 'SCAN_INTERVAL', None) or self.scan_interval) - parallel_updates = getattr( - platform, 'PARALLEL_UPDATES', - int(not hasattr(platform, 'async_setup_platform'))) - + # Use config scan interval, fallback to platform if none set + scan_interval = platform_config.get( + CONF_SCAN_INTERVAL, getattr(platform, 'SCAN_INTERVAL', None)) entity_namespace = platform_config.get(CONF_ENTITY_NAMESPACE) key = (platform_type, scan_interval, entity_namespace) if key not in self._platforms: - entity_platform = self._platforms[key] = EntityPlatform( - hass=self.hass, - logger=self.logger, - domain=self.domain, - platform_name=platform_type, - scan_interval=scan_interval, - parallel_updates=parallel_updates, - entity_namespace=entity_namespace, - async_entities_added_callback=self._async_update_group, + self._platforms[key] = self._async_init_entity_platform( + platform_type, platform, scan_interval, entity_namespace ) - else: - entity_platform = self._platforms[key] - await entity_platform.async_setup( - platform, platform_config, discovery_info) + await self._platforms[key].async_setup(platform_config, discovery_info) @callback def _async_update_group(self): @@ -219,3 +227,20 @@ class EntityComponent(object): await self._async_reset() return conf + + def _async_init_entity_platform(self, platform_type, platform, + scan_interval=None, entity_namespace=None): + """Helper to initialize an entity platform.""" + if scan_interval is None: + scan_interval = self.scan_interval + + return EntityPlatform( + hass=self.hass, + logger=self.logger, + domain=self.domain, + platform_name=platform_type, + platform=platform, + scan_interval=scan_interval, + entity_namespace=entity_namespace, + async_entities_added_callback=self._async_update_group, + ) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 712121bbdb5..00a7e49840e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -1,15 +1,13 @@ """Class to manage the entities for a single platform.""" import asyncio -from datetime import timedelta from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import callback, valid_entity_id, split_entity_id from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.util.async_ import ( run_callback_threadsafe, run_coroutine_threadsafe) -import homeassistant.util.dt as dt_util -from .event import async_track_time_interval, async_track_point_in_time +from .event import async_track_time_interval, async_call_later from .entity_registry import async_get_registry SLOW_SETUP_WARNING = 10 @@ -20,8 +18,8 @@ PLATFORM_NOT_READY_RETRIES = 10 class EntityPlatform(object): """Manage the entities for a single platform.""" - def __init__(self, *, hass, logger, domain, platform_name, scan_interval, - parallel_updates, entity_namespace, + def __init__(self, *, hass, logger, domain, platform_name, platform, + scan_interval, entity_namespace, async_entities_added_callback): """Initialize the entity platform. @@ -38,22 +36,81 @@ class EntityPlatform(object): self.logger = logger self.domain = domain self.platform_name = platform_name + self.platform = platform self.scan_interval = scan_interval - self.parallel_updates = None self.entity_namespace = entity_namespace self.async_entities_added_callback = async_entities_added_callback + self.config_entry = None self.entities = {} self._tasks = [] + # Method to cancel the state change listener self._async_unsub_polling = None + # Method to cancel the retry of setup + self._async_cancel_retry_setup = None self._process_updates = asyncio.Lock(loop=hass.loop) + # Platform is None for the EntityComponent "catch-all" EntityPlatform + # which powers entity_component.add_entities + if platform is None: + self.parallel_updates = None + return + + # Async platforms do all updates in parallel by default + if hasattr(platform, 'async_setup_platform'): + default_parallel_updates = 0 + else: + default_parallel_updates = 1 + + parallel_updates = getattr(platform, 'PARALLEL_UPDATES', + default_parallel_updates) + if parallel_updates: self.parallel_updates = asyncio.Semaphore( parallel_updates, loop=hass.loop) + else: + self.parallel_updates = None - async def async_setup(self, platform, platform_config, discovery_info=None, - tries=0): - """Setup the platform.""" + async def async_setup(self, platform_config, discovery_info=None): + """Setup the platform from a config file.""" + platform = self.platform + hass = self.hass + + @callback + def async_create_setup_task(): + """Get task to setup platform.""" + if getattr(platform, 'async_setup_platform', None): + return platform.async_setup_platform( + hass, platform_config, + self._async_schedule_add_entities, discovery_info + ) + + # This should not be replaced with hass.async_add_job because + # we don't want to track this task in case it blocks startup. + return hass.loop.run_in_executor( + None, platform.setup_platform, hass, platform_config, + self._schedule_add_entities, discovery_info + ) + await self._async_setup_platform(async_create_setup_task) + + async def async_setup_entry(self, config_entry): + """Setup the platform from a config entry.""" + # Store it so that we can save config entry ID in entity registry + self.config_entry = config_entry + platform = self.platform + + @callback + def async_create_setup_task(): + """Get task to setup platform.""" + return platform.async_setup_entry( + self.hass, config_entry, self._async_schedule_add_entities) + + return await self._async_setup_platform(async_create_setup_task) + + async def _async_setup_platform(self, async_create_setup_task, tries=0): + """Helper to setup a platform via config file or config entry. + + async_create_setup_task creates a coroutine that sets up platform. + """ logger = self.logger hass = self.hass full_name = '{}.{}'.format(self.domain, self.platform_name) @@ -65,18 +122,8 @@ class EntityPlatform(object): self.platform_name, SLOW_SETUP_WARNING) try: - if getattr(platform, 'async_setup_platform', None): - task = platform.async_setup_platform( - hass, platform_config, - self._async_schedule_add_entities, discovery_info - ) - else: - # This should not be replaced with hass.async_add_job because - # we don't want to track this task in case it blocks startup. - task = hass.loop.run_in_executor( - None, platform.setup_platform, hass, platform_config, - self._schedule_add_entities, discovery_info - ) + task = async_create_setup_task() + await asyncio.wait_for( asyncio.shield(task, loop=hass.loop), SLOW_SETUP_MAX_WAIT, loop=hass.loop) @@ -91,24 +138,33 @@ class EntityPlatform(object): pending, loop=self.hass.loop) hass.config.components.add(full_name) + return True except PlatformNotReady: tries += 1 wait_time = min(tries, 6) * 30 logger.warning( 'Platform %s not ready yet. Retrying in %d seconds.', self.platform_name, wait_time) - async_track_point_in_time( - hass, self.async_setup( - platform, platform_config, discovery_info, tries), - dt_util.utcnow() + timedelta(seconds=wait_time)) + + async def setup_again(now): + """Run setup again.""" + self._async_cancel_retry_setup = None + await self._async_setup_platform( + async_create_setup_task, tries) + + self._async_cancel_retry_setup = \ + async_call_later(hass, wait_time, setup_again) + return False except asyncio.TimeoutError: logger.error( "Setup of platform %s is taking longer than %s seconds." " Startup will proceed without waiting any longer.", self.platform_name, SLOW_SETUP_MAX_WAIT) + return False except Exception: # pylint: disable=broad-except logger.exception( "Error while setting up platform %s", self.platform_name) + return False finally: warn_task.cancel() @@ -264,6 +320,10 @@ class EntityPlatform(object): This method must be run in the event loop. """ + if self._async_cancel_retry_setup is not None: + self._async_cancel_retry_setup() + self._async_cancel_retry_setup = None + if not self.entities: return @@ -311,7 +371,7 @@ class EntityPlatform(object): self.scan_interval) return - with (await self._process_updates): + async with self._process_updates: tasks = [] for entity in self.entities.values(): if not entity.should_poll: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b5a9c309119..35cc1015aaf 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -83,6 +83,15 @@ class EntityRegistry: """Check if an entity_id is currently registered.""" return entity_id in self.entities + @callback + def async_get_entity_id(self, domain: str, platform: str, unique_id: str): + """Check if an entity_id is currently registered.""" + for entity in self.entities.values(): + if entity.domain == domain and entity.platform == platform and \ + entity.unique_id == unique_id: + return entity.entity_id + return None + @callback def async_generate_entity_id(self, domain, suggested_object_id): """Generate an entity ID that does not conflict. @@ -99,10 +108,9 @@ class EntityRegistry: def async_get_or_create(self, domain, platform, unique_id, *, suggested_object_id=None): """Get entity. Create if it doesn't exist.""" - for entity in self.entities.values(): - if entity.domain == domain and entity.platform == platform and \ - entity.unique_id == unique_id: - return entity + entity_id = self.async_get_entity_id(domain, platform, unique_id) + if entity_id: + return self.entities[entity_id] entity_id = self.async_generate_entity_id( domain, suggested_object_id or '{}_{}'.format(platform, unique_id)) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index aac00b07d7a..eb88a3db369 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -75,7 +75,7 @@ async def async_get_last_state(hass, entity_id: str): if _LOCK not in hass.data: hass.data[_LOCK] = asyncio.Lock(loop=hass.loop) - with (await hass.data[_LOCK]): + async with hass.data[_LOCK]: if DATA_RESTORE_CACHE not in hass.data: await hass.async_add_job( _load_restore_cache, hass) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index abfdde8c8e1..f2ae36e7fd0 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -97,11 +97,16 @@ class Script(): delay = action[CONF_DELAY] - if isinstance(delay, template.Template): - delay = vol.All( - cv.time_period, - cv.positive_timedelta)( - delay.async_render(variables)) + try: + if isinstance(delay, template.Template): + delay = vol.All( + cv.time_period, + cv.positive_timedelta)( + delay.async_render(variables)) + except (TemplateError, vol.Invalid) as ex: + _LOGGER.error("Error rendering '%s' delay template: %s", + self.name, ex) + break unsub = async_track_point_in_utc_time( self.hass, async_script_delay, diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3595b258f12..9114a4db941 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -92,7 +92,7 @@ def extract_entity_ids(hass, service_call, expand_group=True): if not (service_call.data and ATTR_ENTITY_ID in service_call.data): return [] - group = get_component('group') + group = hass.components.group # Entity ID attr can be a list or a string service_ent_id = service_call.data[ATTR_ENTITY_ID] @@ -100,10 +100,10 @@ def extract_entity_ids(hass, service_call, expand_group=True): if expand_group: if isinstance(service_ent_id, str): - return group.expand_entity_ids(hass, [service_ent_id]) + return group.expand_entity_ids([service_ent_id]) return [ent_id for ent_id in - group.expand_entity_ids(hass, service_ent_id)] + group.expand_entity_ids(service_ent_id)] else: @@ -128,7 +128,7 @@ async def async_get_all_descriptions(hass): import homeassistant.components as components component_path = path.dirname(components.__file__) else: - component_path = path.dirname(get_component(domain).__file__) + component_path = path.dirname(get_component(hass, domain).__file__) return path.join(component_path, 'services.yaml') def load_services_files(yaml_files): diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 3dd65aa362c..f523726c388 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -13,10 +13,10 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_UNIT_OF_MEASUREMENT, MATCH_ALL, STATE_UNKNOWN) -from homeassistant.core import State +from homeassistant.core import State, valid_entity_id from homeassistant.exceptions import TemplateError from homeassistant.helpers import location as loc_helper -from homeassistant.loader import bind_hass, get_component +from homeassistant.loader import bind_hass from homeassistant.util import convert from homeassistant.util import dt as dt_util from homeassistant.util import location as loc_util @@ -28,7 +28,7 @@ DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" _RE_NONE_ENTITIES = re.compile(r"distance\(|closest\(", re.I | re.M) _RE_GET_ENTITIES = re.compile( - r"(?:(?:states\.|(?:is_state|is_state_attr|states)" + r"(?:(?:states\.|(?:is_state|is_state_attr|state_attr|states)" r"\((?:[\ \'\"]?))([\w]+\.[\w]+)|([\w]+))", re.I | re.M ) @@ -73,7 +73,8 @@ def extract_entities(template, variables=None): extraction_final.append(result[0]) if variables and result[1] in variables and \ - isinstance(variables[result[1]], str): + isinstance(variables[result[1]], str) and \ + valid_entity_id(variables[result[1]]): extraction_final.append(variables[result[1]]) if extraction_final: @@ -181,6 +182,7 @@ class Template(object): 'distance': template_methods.distance, 'is_state': self.hass.states.is_state, 'is_state_attr': template_methods.is_state_attr, + 'state_attr': template_methods.state_attr, 'states': AllStates(self.hass), }) @@ -347,10 +349,10 @@ class TemplateMethods(object): else: gr_entity_id = str(entities) - group = get_component('group') + group = self._hass.components.group states = [self._hass.states.get(entity_id) for entity_id - in group.expand_entity_ids(self._hass, [gr_entity_id])] + in group.expand_entity_ids([gr_entity_id])] return _wrap_state(loc_helper.closest(latitude, longitude, states)) @@ -404,9 +406,15 @@ class TemplateMethods(object): def is_state_attr(self, entity_id, name, value): """Test if a state is a specific attribute.""" + state_attr = self.state_attr(entity_id, name) + return state_attr is not None and state_attr == value + + def state_attr(self, entity_id, name): + """Get a specific attribute from a state.""" state_obj = self._hass.states.get(entity_id) - return state_obj is not None and \ - state_obj.attributes.get(name) == value + if state_obj is not None: + return state_obj.attributes.get(name) + return None def _resolve_state(self, entity_id_or_state): """Return state or entity_id if given.""" @@ -444,6 +452,38 @@ def logarithm(value, base=math.e): return value +def sine(value): + """Filter to get sine of the value.""" + try: + return math.sin(float(value)) + except (ValueError, TypeError): + return value + + +def cosine(value): + """Filter to get cosine of the value.""" + try: + return math.cos(float(value)) + except (ValueError, TypeError): + return value + + +def tangent(value): + """Filter to get tangent of the value.""" + try: + return math.tan(float(value)) + except (ValueError, TypeError): + return value + + +def square_root(value): + """Filter to get square root of the value.""" + try: + return math.sqrt(float(value)) + except (ValueError, TypeError): + return value + + def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True): """Filter to convert given timestamp to format.""" try: @@ -508,6 +548,39 @@ def forgiving_float(value): return value +def regex_match(value, find='', ignorecase=False): + """Match value using regex.""" + if not isinstance(value, str): + value = str(value) + flags = re.I if ignorecase else 0 + return bool(re.match(find, value, flags)) + + +def regex_replace(value='', find='', replace='', ignorecase=False): + """Replace using regex.""" + if not isinstance(value, str): + value = str(value) + flags = re.I if ignorecase else 0 + regex = re.compile(find, flags) + return regex.sub(replace, value) + + +def regex_search(value, find='', ignorecase=False): + """Search using regex.""" + if not isinstance(value, str): + value = str(value) + flags = re.I if ignorecase else 0 + return bool(re.search(find, value, flags)) + + +def regex_findall_index(value, find='', index=0, ignorecase=False): + """Find all matches using regex and then pick specific match index.""" + if not isinstance(value, str): + value = str(value) + flags = re.I if ignorecase else 0 + return re.findall(find, value, flags)[index] + + @contextfilter def random_every_time(context, values): """Choose a random value. @@ -530,6 +603,10 @@ ENV = TemplateEnvironment() ENV.filters['round'] = forgiving_round ENV.filters['multiply'] = multiply ENV.filters['log'] = logarithm +ENV.filters['sin'] = sine +ENV.filters['cos'] = cosine +ENV.filters['tan'] = tangent +ENV.filters['sqrt'] = square_root ENV.filters['timestamp_custom'] = timestamp_custom ENV.filters['timestamp_local'] = timestamp_local ENV.filters['timestamp_utc'] = timestamp_utc @@ -537,7 +614,18 @@ ENV.filters['is_defined'] = fail_when_undefined ENV.filters['max'] = max ENV.filters['min'] = min ENV.filters['random'] = random_every_time +ENV.filters['regex_match'] = regex_match +ENV.filters['regex_replace'] = regex_replace +ENV.filters['regex_search'] = regex_search +ENV.filters['regex_findall_index'] = regex_findall_index ENV.globals['log'] = logarithm +ENV.globals['sin'] = sine +ENV.globals['cos'] = cosine +ENV.globals['tan'] = tangent +ENV.globals['sqrt'] = square_root +ENV.globals['pi'] = math.pi +ENV.globals['tau'] = math.pi * 2 +ENV.globals['e'] = math.e ENV.globals['float'] = forgiving_float ENV.globals['now'] = dt_util.now ENV.globals['utcnow'] = dt_util.utcnow diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 9d1773de4d2..f1335f73346 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -4,6 +4,7 @@ import logging from typing import Optional # NOQA from os import path +from homeassistant import config_entries from homeassistant.loader import get_component, bind_hass from homeassistant.util.json import load_json @@ -29,14 +30,14 @@ def flatten(data): return recursive_flatten('', data) -def component_translation_file(component, language): +def component_translation_file(hass, component, language): """Return the translation json file location for a component.""" if '.' in component: name = component.split('.', 1)[1] else: name = component - module = get_component(component) + module = get_component(hass, component) component_path = path.dirname(module.__file__) # If loading translations for the package root, (__init__.py), the @@ -89,14 +90,14 @@ async def async_get_component_resources(hass, language): translation_cache = hass.data[TRANSLATION_STRING_CACHE][language] # Get the set of components - components = hass.config.components + components = hass.config.components | set(config_entries.FLOWS) # Calculate the missing components missing_components = components - set(translation_cache) missing_files = {} for component in missing_components: missing_files[component] = component_translation_file( - component, language) + hass, component, language) # Load missing files if missing_files: diff --git a/homeassistant/loader.py b/homeassistant/loader.py index a3ce2a13f56..ce93c8705b5 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -6,15 +6,13 @@ documentation as possible to keep it understandable. Components can be accessed via hass.components.switch from your code. If you want to retrieve a platform that is part of a component, you should -call get_component('switch.your_platform'). In both cases the config directory -is checked to see if it contains a user provided version. If not available it -will check the built-in components and platforms. +call get_component(hass, 'switch.your_platform'). In both cases the config +directory is checked to see if it contains a user provided version. If not +available it will check the built-in components and platforms. """ import functools as ft import importlib import logging -import os -import pkgutil import sys from types import ModuleType @@ -33,111 +31,57 @@ PREPARED = False DEPENDENCY_BLACKLIST = set(('config',)) -# List of available components -AVAILABLE_COMPONENTS = [] # type: List[str] - -# Dict of loaded components mapped name => module -_COMPONENT_CACHE = {} # type: Dict[str, ModuleType] - _LOGGER = logging.getLogger(__name__) -def prepare(hass: 'HomeAssistant'): - """Prepare the loading of components. - - This method needs to run in an executor. - """ - global PREPARED # pylint: disable=global-statement - - # Load the built-in components - import homeassistant.components as components - - AVAILABLE_COMPONENTS.clear() - - AVAILABLE_COMPONENTS.extend( - item[1] for item in - pkgutil.iter_modules(components.__path__, 'homeassistant.components.')) - - # Look for available custom components - custom_path = hass.config.path("custom_components") - - if os.path.isdir(custom_path): - # Ensure we can load custom components using Pythons import - sys.path.insert(0, hass.config.config_dir) - - # We cannot use the same approach as for built-in components because - # custom components might only contain a platform for a component. - # ie custom_components/switch/some_platform.py. Using pkgutil would - # not give us the switch component (and neither should it). - - # Assumption: the custom_components dir only contains directories or - # python components. If this assumption is not true, HA won't break, - # just might output more errors. - for fil in os.listdir(custom_path): - if fil == '__pycache__': - continue - elif os.path.isdir(os.path.join(custom_path, fil)): - AVAILABLE_COMPONENTS.append('custom_components.{}'.format(fil)) - else: - # For files we will strip out .py extension - AVAILABLE_COMPONENTS.append( - 'custom_components.{}'.format(fil[0:-3])) - - PREPARED = True +DATA_KEY = 'components' +PATH_CUSTOM_COMPONENTS = 'custom_components' +PACKAGE_COMPONENTS = 'homeassistant.components' -def set_component(comp_name: str, component: ModuleType) -> None: +def set_component(hass, comp_name: str, component: ModuleType) -> None: """Set a component in the cache. Async friendly. """ - _check_prepared() - - _COMPONENT_CACHE[comp_name] = component + cache = hass.data.get(DATA_KEY) + if cache is None: + cache = hass.data[DATA_KEY] = {} + cache[comp_name] = component -def get_platform(domain: str, platform: str) -> Optional[ModuleType]: +def get_platform(hass, domain: str, platform: str) -> Optional[ModuleType]: """Try to load specified platform. Async friendly. """ - return get_component(PLATFORM_FORMAT.format(domain, platform)) + return get_component(hass, PLATFORM_FORMAT.format(domain, platform)) -def get_component(comp_name) -> Optional[ModuleType]: +def get_component(hass, comp_or_platform) -> Optional[ModuleType]: """Try to load specified component. Looks in config dir first, then built-in components. Only returns it if also found to be valid. - Async friendly. """ - if comp_name in _COMPONENT_CACHE: - return _COMPONENT_CACHE[comp_name] + try: + return hass.data[DATA_KEY][comp_or_platform] + except KeyError: + pass - _check_prepared() - - # If we ie. try to load custom_components.switch.wemo but the parent - # custom_components.switch does not exist, importing it will trigger - # an exception because it will try to import the parent. - # Because of this behavior, we will approach loading sub components - # with caution: only load it if we can verify that the parent exists. - # We do not want to silent the ImportErrors as they provide valuable - # information to track down when debugging Home Assistant. + cache = hass.data.get(DATA_KEY) + if cache is None: + # Only insert if it's not there (happens during tests) + if sys.path[0] != hass.config.config_dir: + sys.path.insert(0, hass.config.config_dir) + cache = hass.data[DATA_KEY] = {} # First check custom, then built-in - potential_paths = ['custom_components.{}'.format(comp_name), - 'homeassistant.components.{}'.format(comp_name)] + potential_paths = ['custom_components.{}'.format(comp_or_platform), + 'homeassistant.components.{}'.format(comp_or_platform)] for path in potential_paths: - # Validate here that root component exists - # If path contains a '.' we are specifying a sub-component - # Using rsplit we get the parent component from sub-component - root_comp = path.rsplit(".", 1)[0] if '.' in comp_name else path - - if root_comp not in AVAILABLE_COMPONENTS: - continue - try: module = importlib.import_module(path) @@ -149,24 +93,33 @@ def get_component(comp_name) -> Optional[ModuleType]: # This prevents that when only # custom_components/switch/some_platform.py exists, # the import custom_components.switch would succeed. - if module.__spec__.origin == 'namespace': + if module.__spec__ and module.__spec__.origin == 'namespace': continue - _LOGGER.info("Loaded %s from %s", comp_name, path) + _LOGGER.info("Loaded %s from %s", comp_or_platform, path) - _COMPONENT_CACHE[comp_name] = module + cache[comp_or_platform] = module return module except ImportError as err: # This error happens if for example custom_components/switch # exists and we try to load switch.demo. - if str(err) != "No module named '{}'".format(path): + # Ignore errors for custom_components, custom_components.switch + # and custom_components.switch.demo. + white_listed_errors = [] + parts = [] + for part in path.split('.'): + parts.append(part) + white_listed_errors.append( + "No module named '{}'".format('.'.join(parts))) + + if str(err) not in white_listed_errors: _LOGGER.exception( ("Error loading %s. Make sure all " "dependencies are installed"), path) - _LOGGER.error("Unable to find component %s", comp_name) + _LOGGER.error("Unable to find component %s", comp_or_platform) return None @@ -180,7 +133,7 @@ class Components: def __getattr__(self, comp_name): """Fetch a component.""" - component = get_component(comp_name) + component = get_component(self._hass, comp_name) if component is None: raise ImportError('Unable to load {}'.format(comp_name)) wrapped = ModuleWrapper(self._hass, component) @@ -230,7 +183,7 @@ def bind_hass(func): return func -def load_order_component(comp_name: str) -> OrderedSet: +def load_order_component(hass, comp_name: str) -> OrderedSet: """Return an OrderedSet of components in the correct order of loading. Raises HomeAssistantError if a circular dependency is detected. @@ -238,16 +191,16 @@ def load_order_component(comp_name: str) -> OrderedSet: Async friendly. """ - return _load_order_component(comp_name, OrderedSet(), set()) + return _load_order_component(hass, comp_name, OrderedSet(), set()) -def _load_order_component(comp_name: str, load_order: OrderedSet, +def _load_order_component(hass, comp_name: str, load_order: OrderedSet, loading: Set) -> OrderedSet: """Recursive function to get load order of components. Async friendly. """ - component = get_component(comp_name) + component = get_component(hass, comp_name) # If None it does not exist, error already thrown by get_component. if component is None: @@ -266,7 +219,8 @@ def _load_order_component(comp_name: str, load_order: OrderedSet, comp_name, dependency) return OrderedSet() - dep_load_order = _load_order_component(dependency, load_order, loading) + dep_load_order = _load_order_component( + hass, dependency, load_order, loading) # length == 0 means error loading dependency or children if not dep_load_order: @@ -280,14 +234,3 @@ def _load_order_component(comp_name: str, load_order: OrderedSet, loading.remove(comp_name) return load_order - - -def _check_prepared() -> None: - """Issue a warning if loader.prepare() has never been called. - - Async friendly. - """ - if not PREPARED: - _LOGGER.warning(( - "You did not call loader.prepare() yet. " - "Certain functionality might not be working")) diff --git a/homeassistant/monkey_patch.py b/homeassistant/monkey_patch.py index 5aa051f2bb5..d5c629c9d34 100644 --- a/homeassistant/monkey_patch.py +++ b/homeassistant/monkey_patch.py @@ -61,7 +61,7 @@ def disable_c_asyncio(): def find_module(self, fullname, path=None): """Find a module.""" if fullname == self.PATH_TRIGGER: - # We lint in Py34, exception is introduced in Py36 + # We lint in Py35, exception is introduced in Py36 # pylint: disable=undefined-variable raise ModuleNotFoundError() # noqa return None diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c91d7c84aa9..e76dc24d9dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,15 +1,15 @@ -requests==2.18.4 -pyyaml>=3.11,<4 -pytz>=2017.02 -pip>=8.0.3 +aiohttp==3.2.1 +astral==1.6.1 +async_timeout==3.0.0 +attrs==18.1.0 +certifi>=2018.04.16 jinja2>=2.10 -voluptuous==0.11.1 +pip>=8.0.3 +pytz>=2018.04 +pyyaml>=3.11,<4 +requests==2.18.4 typing>=3,<4 -aiohttp==3.0.7 -async_timeout==2.0.0 -astral==1.6 -certifi>=2017.4.17 -attrs==17.4.0 +voluptuous==0.11.1 # Breaks Python 3.6 and is not needed for our supported Python versions enum34==1000000000.0.0 diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py new file mode 100644 index 00000000000..b4f1ddd2f11 --- /dev/null +++ b/homeassistant/scripts/auth.py @@ -0,0 +1,78 @@ +"""Script to manage users for the Home Assistant auth provider.""" +import argparse +import os + +from homeassistant.config import get_default_config_dir +from homeassistant.auth_providers import homeassistant as hass_auth + + +def run(args): + """Handle Home Assistant auth provider script.""" + parser = argparse.ArgumentParser( + description=("Manage Home Assistant users")) + parser.add_argument( + '--script', choices=['auth']) + parser.add_argument( + '-c', '--config', + default=get_default_config_dir(), + help="Directory that contains the Home Assistant configuration") + + subparsers = parser.add_subparsers() + parser_list = subparsers.add_parser('list') + parser_list.set_defaults(func=list_users) + + parser_add = subparsers.add_parser('add') + parser_add.add_argument('username', type=str) + parser_add.add_argument('password', type=str) + parser_add.set_defaults(func=add_user) + + parser_validate_login = subparsers.add_parser('validate') + parser_validate_login.add_argument('username', type=str) + parser_validate_login.add_argument('password', type=str) + parser_validate_login.set_defaults(func=validate_login) + + parser_change_pw = subparsers.add_parser('change_password') + parser_change_pw.add_argument('username', type=str) + parser_change_pw.add_argument('new_password', type=str) + parser_change_pw.set_defaults(func=change_password) + + args = parser.parse_args(args) + path = os.path.join(os.getcwd(), args.config, hass_auth.PATH_DATA) + args.func(hass_auth.load_data(path), args) + + +def list_users(data, args): + """List the users.""" + count = 0 + for user in data.users: + count += 1 + print(user['username']) + + print() + print("Total users:", count) + + +def add_user(data, args): + """Create a user.""" + data.add_user(args.username, args.password) + data.save() + print("User created") + + +def validate_login(data, args): + """Validate a login.""" + try: + data.validate_login(args.username, args.password) + print("Auth valid") + except hass_auth.InvalidAuth: + print("Auth invalid") + + +def change_password(data, args): + """Change password.""" + try: + data.change_password(args.username, args.new_password) + data.save() + print("Password changed") + except hass_auth.InvalidUser: + print("User not found") diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 1a58757d17f..3a1ffa82d47 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -16,12 +16,12 @@ from homeassistant import bootstrap, core, loader from homeassistant.config import ( get_default_config_dir, CONF_CORE, CORE_CONFIG_SCHEMA, CONF_PACKAGES, merge_packages_config, _format_config_error, - find_config_file, load_yaml_config_file, get_component, - extract_domain_configs, config_per_platform, get_platform) + find_config_file, load_yaml_config_file, + extract_domain_configs, config_per_platform) import homeassistant.util.yaml as yaml from homeassistant.exceptions import HomeAssistantError -REQUIREMENTS = ('colorlog==3.1.2',) +REQUIREMENTS = ('colorlog==3.1.4',) if system() == 'Windows': # Ensure colorama installed for colorlog on Windows REQUIREMENTS += ('colorama<=1',) @@ -58,7 +58,7 @@ def color(the_color, *args, reset=None): def run(script_args: List) -> int: """Handle ensure config commandline script.""" parser = argparse.ArgumentParser( - description=("Check Home Assistant configuration.")) + description="Check Home Assistant configuration.") parser.add_argument( '--script', choices=['check_config']) parser.add_argument( @@ -95,9 +95,12 @@ def run(script_args: List) -> int: if args.files: print(color(C_HEAD, 'yaml files'), '(used /', color('red', 'not used') + ')') - # Python 3.5 gets a recursive, but not in 3.4 - for yfn in sorted(glob(os.path.join(config_dir, '*.yaml')) + - glob(os.path.join(config_dir, '*/*.yaml'))): + deps = os.path.join(config_dir, 'deps') + yaml_files = [f for f in glob(os.path.join(config_dir, '**/*.yaml'), + recursive=True) + if not f.startswith(deps)] + + for yfn in sorted(yaml_files): the_color = '' if yfn in res['yaml_files'] else 'red' print(color(the_color, '-', yfn)) @@ -198,18 +201,10 @@ def check(config_dir, secrets=False): yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) try: - class HassConfig(): - """Hass object with config.""" - - def __init__(self, conf_dir): - """Init the config_dir.""" - self.config = core.Config() - self.config.config_dir = conf_dir - - loader.prepare(HassConfig(config_dir)) - - res['components'] = check_ha_config_file(config_dir) + hass = core.HomeAssistant() + hass.config.config_dir = config_dir + res['components'] = check_ha_config_file(hass) res['secret_cache'] = OrderedDict(yaml.__SECRET_CACHE) for err in res['components'].errors: @@ -219,6 +214,7 @@ def check(config_dir, secrets=False): res['except'].setdefault(domain, []).append(err.config) except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("BURB") print(color('red', 'Fatal error while loading config:'), str(err)) res['except'].setdefault(ERROR_STR, []).append(str(err)) finally: @@ -249,7 +245,7 @@ def dump_dict(layer, indent_count=3, listi=False, **kwargs): """ def sort_dict_key(val): """Return the dict key for sorting.""" - key = str.lower(val[0]) + key = str(val[0]).lower() return '0' if key == 'platform' else key indent_str = indent_count * ' ' @@ -258,10 +254,10 @@ def dump_dict(layer, indent_count=3, listi=False, **kwargs): if isinstance(layer, Dict): for key, value in sorted(layer.items(), key=sort_dict_key): if isinstance(value, (dict, list)): - print(indent_str, key + ':', line_info(value, **kwargs)) + print(indent_str, str(key) + ':', line_info(value, **kwargs)) dump_dict(value, indent_count + 2) else: - print(indent_str, key + ':', value) + print(indent_str, str(key) + ':', value) indent_str = indent_count * ' ' if isinstance(layer, Sequence): for i in layer: @@ -287,8 +283,9 @@ class HomeAssistantConfig(OrderedDict): return self -def check_ha_config_file(config_dir): +def check_ha_config_file(hass): """Check if Home Assistant configuration file is valid.""" + config_dir = hass.config.config_dir result = HomeAssistantConfig() def _pack_error(package, component, config, message): @@ -327,7 +324,7 @@ def check_ha_config_file(config_dir): # Merge packages merge_packages_config( - config, core_config.get(CONF_PACKAGES, {}), _pack_error) + hass, config, core_config.get(CONF_PACKAGES, {}), _pack_error) del core_config[CONF_PACKAGES] # Ensure we have no None values after merge @@ -340,7 +337,7 @@ def check_ha_config_file(config_dir): # Process and validate config for domain in components: - component = get_component(domain) + component = loader.get_component(hass, domain) if not component: result.add_error("Component not found: {}".format(domain)) continue @@ -372,7 +369,7 @@ def check_ha_config_file(config_dir): platforms.append(p_validated) continue - platform = get_platform(domain, p_name) + platform = loader.get_platform(hass, domain, p_name) if platform is None: result.add_error( diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py index 64ad09bcd70..e02305b5fbb 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ import os from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring==11.0.0', 'keyrings.alt==2.3'] +REQUIREMENTS = ['keyring==12.2.1', 'keyrings.alt==3.1'] def run(args): diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 169a160af65..1664653f2a7 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -98,14 +98,14 @@ async def _async_setup_component(hass: core.HomeAssistant, _LOGGER.error("Setup failed for %s: %s", domain, msg) async_notify_setup_error(hass, domain, link) - component = loader.get_component(domain) + component = loader.get_component(hass, domain) if not component: log_error("Component not found.", False) return False # Validate no circular dependencies - components = loader.load_order_component(domain) + components = loader.load_order_component(hass, domain) # OrderedSet is empty if component or dependencies could not be resolved if not components: @@ -139,10 +139,11 @@ async def _async_setup_component(hass: core.HomeAssistant, try: if hasattr(component, 'async_setup'): - result = await component.async_setup(hass, processed_config) + result = await component.async_setup( # type: ignore + hass, processed_config) else: result = await hass.async_add_job( - component.setup, hass, processed_config) + component.setup, hass, processed_config) # type: ignore except Exception: # pylint: disable=broad-except _LOGGER.exception("Error during setup of component %s", domain) async_notify_setup_error(hass, domain, True) @@ -159,20 +160,21 @@ async def _async_setup_component(hass: core.HomeAssistant, elif result is not True: log_error("Component did not return boolean if setup was successful. " "Disabling component.") - loader.set_component(domain, None) + loader.set_component(hass, domain, None) return False for entry in hass.config_entries.async_entries(domain): await entry.async_setup(hass, component=component) - hass.config.components.add(component.DOMAIN) + hass.config.components.add(component.DOMAIN) # type: ignore # Cleanup if domain in hass.data[DATA_SETUP]: hass.data[DATA_SETUP].pop(domain) hass.bus.async_fire( - EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN} + EVENT_COMPONENT_LOADED, + {ATTR_COMPONENT: component.DOMAIN} # type: ignore ) return True @@ -193,7 +195,7 @@ async def async_prepare_setup_platform(hass: core.HomeAssistant, config, platform_path, msg) async_notify_setup_error(hass, platform_path) - platform = loader.get_platform(domain, platform_name) + platform = loader.get_platform(hass, domain, platform_name) # Not found if platform is None: diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index a869251dc3c..a8a84c6c880 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -13,7 +13,7 @@ from functools import wraps from types import MappingProxyType from unicodedata import normalize -from typing import Any, Optional, TypeVar, Callable, Sequence, KeysView, Union +from typing import Any, Optional, TypeVar, Callable, KeysView, Union, Iterable from .dt import as_local, utcnow @@ -72,7 +72,7 @@ def convert(value: T, to_type: Callable[[T], U], def ensure_unique_string(preferred_string: str, current_strings: - Union[Sequence[str], KeysView[str]]) -> str: + Union[Iterable[str], KeysView[str]]) -> str: """Return a string that is not present in current_strings. If preferred string exists will append _2, _3, .. @@ -261,6 +261,16 @@ class Throttle(object): def __call__(self, method): """Caller for the throttle.""" + # Make sure we return a coroutine if the method is async. + if asyncio.iscoroutinefunction(method): + async def throttled_value(): + """Stand-in function for when real func is being throttled.""" + return None + else: + def throttled_value(): + """Stand-in function for when real func is being throttled.""" + return None + if self.limit_no_throttle is not None: method = Throttle(self.limit_no_throttle)(method) @@ -277,16 +287,6 @@ class Throttle(object): is_func = (not hasattr(method, '__self__') and '.' not in method.__qualname__.split('..')[-1]) - # Make sure we return a coroutine if the method is async. - if asyncio.iscoroutinefunction(method): - async def throttled_value(): - """Stand-in function for when real func is being throttled.""" - return None - else: - def throttled_value(): - """Stand-in function for when real func is being throttled.""" - return None - @wraps(method) def wrapper(*args, **kwargs): """Wrap that allows wrapped to be called only once per min_time. diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 70863a0ab90..32e9df70a03 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -173,11 +173,18 @@ def color_name_to_rgb(color_name): return hex_value +# pylint: disable=invalid-name, invalid-sequence-index +def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float]: + """Convert from RGB color to XY color.""" + return color_RGB_to_xy_brightness(iR, iG, iB)[:2] + + # Taken from: # http://www.developers.meethue.com/documentation/color-conversions-rgb-xy # License: Code is given as is. Use at your own risk and discretion. # pylint: disable=invalid-name, invalid-sequence-index -def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float, int]: +def color_RGB_to_xy_brightness( + iR: int, iG: int, iB: int) -> Tuple[float, float, int]: """Convert from RGB color to XY color.""" if iR + iG + iB == 0: return 0.0, 0.0, 0 @@ -196,7 +203,7 @@ def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float, int]: # Wide RGB D65 conversion formula X = R * 0.664511 + G * 0.154324 + B * 0.162028 - Y = R * 0.313881 + G * 0.668433 + B * 0.047685 + Y = R * 0.283881 + G * 0.668433 + B * 0.047685 Z = R * 0.000088 + G * 0.072310 + B * 0.986039 # Convert XYZ to xy @@ -210,6 +217,11 @@ def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float, int]: return round(x, 3), round(y, 3), brightness +def color_xy_to_RGB(vX: float, vY: float) -> Tuple[int, int, int]: + """Convert from XY to a normalized RGB.""" + return color_xy_brightness_to_RGB(vX, vY, 255) + + # Converted to Python from Obj-C, original source from: # http://www.developers.meethue.com/documentation/color-conversions-rgb-xy # pylint: disable=invalid-sequence-index @@ -307,6 +319,12 @@ def color_RGB_to_hsv(iR: int, iG: int, iB: int) -> Tuple[float, float, float]: return round(fHSV[0]*360, 3), round(fHSV[1]*100, 3), round(fHSV[2]*100, 3) +# pylint: disable=invalid-sequence-index +def color_RGB_to_hs(iR: int, iG: int, iB: int) -> Tuple[float, float]: + """Convert an rgb color to its hs representation.""" + return color_RGB_to_hsv(iR, iG, iB)[:2] + + # pylint: disable=invalid-sequence-index def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> Tuple[int, int, int]: """Convert an hsv color into its rgb representation. @@ -320,12 +338,24 @@ def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> Tuple[int, int, int]: # pylint: disable=invalid-sequence-index -def color_xy_to_hs(vX: float, vY: float) -> Tuple[int, int]: +def color_hs_to_RGB(iH: float, iS: float) -> Tuple[int, int, int]: + """Convert an hsv color into its rgb representation.""" + return color_hsv_to_RGB(iH, iS, 100) + + +# pylint: disable=invalid-sequence-index +def color_xy_to_hs(vX: float, vY: float) -> Tuple[float, float]: """Convert an xy color to its hs representation.""" - h, s, _ = color_RGB_to_hsv(*color_xy_brightness_to_RGB(vX, vY, 255)) + h, s, _ = color_RGB_to_hsv(*color_xy_to_RGB(vX, vY)) return (h, s) +# pylint: disable=invalid-sequence-index +def color_hs_to_xy(iH: float, iS: float) -> Tuple[float, float]: + """Convert an hs color to its xy representation.""" + return color_RGB_to_xy(*color_hs_to_RGB(iH, iS)) + + # pylint: disable=invalid-sequence-index def _match_max_scale(input_colors: Tuple[int, ...], output_colors: Tuple[int, ...]) -> Tuple[int, ...]: @@ -374,6 +404,11 @@ def rgb_hex_to_rgb_list(hex_string): len(hex_string) // 3)] +def color_temperature_to_hs(color_temperature_kelvin): + """Return an hs color from a color temperature in Kelvin.""" + return color_RGB_to_hs(*color_temperature_to_rgb(color_temperature_kelvin)) + + def color_temperature_to_rgb(color_temperature_kelvin): """ Return an RGB color from a color temperature in Kelvin. diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index 0cd0b14d3ab..dae8ed17dc9 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -10,7 +10,7 @@ from typing import Any, Optional, Tuple, Dict import requests ELEVATION_URL = 'http://maps.googleapis.com/maps/api/elevation/json' -FREEGEO_API = 'https://freegeoip.io/json/' +FREEGEO_API = 'https://freegeoip.net/json/' IP_API = 'http://ip-api.com/json' # Constants from https://github.com/maurycyp/vincenty diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index f7306cae98b..10b43445184 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -49,17 +49,16 @@ class AsyncHandler(object): """Wrap close to handler.""" self.emit(None) - @asyncio.coroutine - def async_close(self, blocking=False): + async def async_close(self, blocking=False): """Close the handler. When blocking=True, will wait till closed. """ - yield from self._queue.put(None) + await self._queue.put(None) if blocking: while self._thread.is_alive(): - yield from asyncio.sleep(0, loop=self.loop) + await asyncio.sleep(0, loop=self.loop) def emit(self, record): """Process a record.""" diff --git a/homeassistant/util/temperature.py b/homeassistant/util/temperature.py index b7e2412f293..913d6456906 100644 --- a/homeassistant/util/temperature.py +++ b/homeassistant/util/temperature.py @@ -3,17 +3,22 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_NOT_RECOGNIZED_TEMPLATE, TEMPERATURE) -def fahrenheit_to_celsius(fahrenheit: float) -> float: +def fahrenheit_to_celsius(fahrenheit: float, interval: bool = False) -> float: """Convert a temperature in Fahrenheit to Celsius.""" + if interval: + return fahrenheit / 1.8 return (fahrenheit - 32.0) / 1.8 -def celsius_to_fahrenheit(celsius: float) -> float: +def celsius_to_fahrenheit(celsius: float, interval: bool = False) -> float: """Convert a temperature in Celsius to Fahrenheit.""" + if interval: + return celsius * 1.8 return celsius * 1.8 + 32.0 -def convert(temperature: float, from_unit: str, to_unit: str) -> float: +def convert(temperature: float, from_unit: str, to_unit: str, + interval: bool = False) -> float: """Convert a temperature from one unit to another.""" if from_unit not in (TEMP_CELSIUS, TEMP_FAHRENHEIT): raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format( @@ -25,5 +30,5 @@ def convert(temperature: float, from_unit: str, to_unit: str) -> float: if from_unit == to_unit: return temperature elif from_unit == TEMP_CELSIUS: - return celsius_to_fahrenheit(temperature) - return fahrenheit_to_celsius(temperature) + return celsius_to_fahrenheit(temperature, interval) + return fahrenheit_to_celsius(temperature, interval) diff --git a/pylintrc b/pylintrc index 85a44782af1..df839b379b5 100644 --- a/pylintrc +++ b/pylintrc @@ -41,3 +41,7 @@ disable= [EXCEPTIONS] overgeneral-exceptions=Exception,HomeAssistantError + +# For attrs +[typecheck] +ignored-classes=_CountingAttr diff --git a/requirements_all.txt b/requirements_all.txt index c4c640b063a..59cec2c1e6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,28 +1,37 @@ # Home Assistant core -requests==2.18.4 -pyyaml>=3.11,<4 -pytz>=2017.02 -pip>=8.0.3 +aiohttp==3.2.1 +astral==1.6.1 +async_timeout==3.0.0 +attrs==18.1.0 +certifi>=2018.04.16 jinja2>=2.10 -voluptuous==0.11.1 +pip>=8.0.3 +pytz>=2018.04 +pyyaml>=3.11,<4 +requests==2.18.4 typing>=3,<4 -aiohttp==3.0.7 -async_timeout==2.0.0 -astral==1.6 -certifi>=2017.4.17 -attrs==17.4.0 +voluptuous==0.11.1 # homeassistant.components.nuimo_controller --only-binary=all https://github.com/getSenic/nuimo-linux-python/archive/29fc42987f74d8090d0e2382e8f248ff5990b8c9.zip#nuimo==1.0.0 +# homeassistant.components.sensor.sht31 +Adafruit-GPIO==1.0.3 + +# homeassistant.components.sensor.sht31 +Adafruit-SHT31==1.0.2 + # homeassistant.components.bbb_gpio # Adafruit_BBIO==1.0.0 # homeassistant.components.doorbird -DoorBirdPy==0.1.2 +DoorBirdPy==0.1.3 # homeassistant.components.homekit -HAP-python==1.1.7 +HAP-python==2.2.2 + +# homeassistant.components.notify.mastodon +Mastodon.py==1.2.2 # homeassistant.components.isy994 PyISY==1.1.0 @@ -37,7 +46,7 @@ PyMVGLive==1.1.4 PyMata==2.14 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.8.3 +PyXiaomiGateway==0.9.5 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 @@ -52,13 +61,16 @@ SoCo==0.14 TravisPy==0.3.5 # homeassistant.components.notify.twitter -TwitterAPI==2.4.10 +TwitterAPI==2.5.4 + +# homeassistant.components.sensor.waze_travel_time +WazeRouteCalculator==0.5 # homeassistant.components.notify.yessssms YesssSMS==0.1.1b3 # homeassistant.components.abode -abodepy==0.12.2 +abodepy==0.13.1 # homeassistant.components.media_player.frontier_silicon afsapi==0.0.3 @@ -71,10 +83,10 @@ aiodns==1.1.1 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp_cors==0.6.0 +aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==0.3.0 +aiohue==1.5.0 # homeassistant.components.sensor.imap aioimaplib==0.7.13 @@ -92,10 +104,10 @@ aiopvapi==1.5.4 alarmdecoder==1.13.2 # homeassistant.components.sensor.alpha_vantage -alpha_vantage==1.9.0 +alpha_vantage==2.0.0 # homeassistant.components.amcrest -amcrest==1.2.1 +amcrest==1.2.2 # homeassistant.components.media_player.anthemav anthemav==1.1.8 @@ -125,7 +137,7 @@ basicmodem==0.7 batinfo==0.4.2 # homeassistant.components.sensor.eddystone_temperature -# beacontools[scan]==1.2.1 +# beacontools[scan]==1.2.3 # homeassistant.components.device_tracker.linksys_ap # homeassistant.components.sensor.geizhals @@ -134,10 +146,10 @@ batinfo==0.4.2 beautifulsoup4==4.6.0 # homeassistant.components.zha -bellows==0.5.1 +bellows==0.6.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.4.1 +bimmer_connected==0.5.1 # homeassistant.components.blink blinkpy==0.6.0 @@ -149,7 +161,7 @@ blinkstick==1.1.8 # blinkt==0.1.0 # homeassistant.components.sensor.bitcoin -blockchain==1.4.0 +blockchain==1.4.4 # homeassistant.components.light.decora # bluepy==1.1.4 @@ -166,6 +178,13 @@ boto3==1.4.7 # homeassistant.scripts.credstash botocore==1.7.34 +# homeassistant.components.sensor.broadlink +# homeassistant.components.switch.broadlink +broadlink==0.9.0 + +# homeassistant.components.device_tracker.bluetooth_tracker +bt_proximity==0.1.2 + # homeassistant.components.sensor.buienradar # homeassistant.components.weather.buienradar buienradar==0.91 @@ -180,15 +199,26 @@ ciscosparkapi==0.4.2 coinbase==2.1.0 # homeassistant.components.sensor.coinmarketcap -coinmarketcap==4.2.1 +coinmarketcap==5.0.3 # homeassistant.scripts.check_config -colorlog==3.1.2 +colorlog==3.1.4 # homeassistant.components.alarm_control_panel.concord232 # homeassistant.components.binary_sensor.concord232 concord232==0.15 +# homeassistant.components.climate.eq3btsmart +# homeassistant.components.device_tracker.xiaomi_miio +# homeassistant.components.fan.xiaomi_miio +# homeassistant.components.light.xiaomi_miio +# homeassistant.components.remote.xiaomi_miio +# homeassistant.components.sensor.eddystone_temperature +# homeassistant.components.sensor.xiaomi_miio +# homeassistant.components.switch.xiaomi_miio +# homeassistant.components.vacuum.xiaomi_miio +construct==2.9.41 + # homeassistant.scripts.credstash # credstash==1.14.0 @@ -213,13 +243,13 @@ defusedxml==0.5.0 # homeassistant.components.sensor.deluge # homeassistant.components.switch.deluge -deluge-client==1.0.5 +deluge-client==1.4.0 # homeassistant.components.media_player.denonavr -denonavr==0.6.1 +denonavr==0.7.2 # homeassistant.components.media_player.directv -directpy==0.2 +directpy==0.5 # homeassistant.components.sensor.discogs discogs_client==2.2.1 @@ -228,7 +258,7 @@ discogs_client==2.2.1 discord.py==0.16.12 # homeassistant.components.updater -distro==1.2.0 +distro==1.3.0 # homeassistant.components.switch.digitalloggers dlipower==0.7.165 @@ -247,7 +277,7 @@ dsmr_parser==0.11 dweepy==0.3.0 # homeassistant.components.sensor.eliqonline -eliqonline==1.0.13 +eliqonline==1.0.14 # homeassistant.components.enocean enocean==0.40 @@ -278,6 +308,9 @@ fedexdeliverymanager==1.0.6 # homeassistant.components.sensor.geo_rss_events feedparser==5.2.1 +# homeassistant.components.sensor.fints +fints==0.2.1 + # homeassistant.components.sensor.fitbit fitbit==0.3.0 @@ -287,6 +320,9 @@ fixerio==0.1.1 # homeassistant.components.light.flux_led flux_led==0.21 +# homeassistant.components.sensor.foobot +foobot_async==0.3.1 + # homeassistant.components.notify.free_mobile freesms==0.1.2 @@ -308,7 +344,7 @@ gTTS-token==1.1.1 gearbest_parser==1.0.5 # homeassistant.components.sensor.gitter -gitterpy==0.1.6 +gitterpy==0.1.7 # homeassistant.components.notify.gntp gntp==1.0.3 @@ -332,13 +368,13 @@ gstreamer-player==1.1.0 ha-ffmpeg==1.9 # homeassistant.components.media_player.philips_js -ha-philipsjs==0.0.2 +ha-philipsjs==0.0.4 # homeassistant.components.sensor.geo_rss_events haversine==0.4.5 # homeassistant.components.mqtt.server -hbmqtt==0.9.1 +hbmqtt==0.9.2 # homeassistant.components.climate.heatmiser heatmiserV3==0.9.1 @@ -350,10 +386,16 @@ hikvision==0.4 hipnotify==1.0.8 # homeassistant.components.binary_sensor.workday -holidays==0.9.4 +holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180310.0 +home-assistant-frontend==20180603.0 + +# homeassistant.components.homekit_controller +# homekit==0.6 + +# homeassistant.components.homematicip_cloud +homematicip==0.9.4 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a @@ -367,10 +409,6 @@ httplib2==0.10.3 # homeassistant.components.media_player.braviatv https://github.com/aparraga/braviarc/archive/0.3.7.zip#braviarc==0.3.7 -# homeassistant.components.sensor.broadlink -# homeassistant.components.switch.broadlink -https://github.com/balloob/python-broadlink/archive/3580ff2eaccd267846f14246d6ede6e30671f7c6.zip#broadlink==0.5.1 - # homeassistant.components.media_player.spotify https://github.com/happyleavesaoc/spotipy/archive/544614f4b1d508201d363e84e871f86c90aa26b2.zip#spotipy==2.4.4 @@ -392,6 +430,9 @@ https://github.com/soldag/pyflic/archive/0.4.zip#pyflic==0.4 # homeassistant.components.media_player.lg_netcast https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 +# homeassistant.components.hydrawise +hydrawiser==0.1.1 + # homeassistant.components.sensor.bh1750 # homeassistant.components.sensor.bme280 # homeassistant.components.sensor.htu21d @@ -411,7 +452,10 @@ influxdb==5.0.0 insteonlocal==0.53 # homeassistant.components.insteon_plm -insteonplm==0.8.2 +insteonplm==0.9.2 + +# homeassistant.components.sensor.iperf3 +iperf3==0.1.10 # homeassistant.components.verisure jsonpath==0.75 @@ -424,10 +468,16 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.6 # homeassistant.scripts.keyring -keyring==11.0.0 +keyring==12.2.1 # homeassistant.scripts.keyring -keyrings.alt==2.3 +keyrings.alt==3.1 + +# homeassistant.components.konnected +konnected==0.1.2 + +# homeassistant.components.eufy +lakeside==0.7 # homeassistant.components.device_tracker.owntracks # homeassistant.components.device_tracker.owntracks_http @@ -455,7 +505,7 @@ lightify==1.0.6.1 limitlessled==1.1.0 # homeassistant.components.linode -linode-api==4.1.4b2 +linode-api==4.1.9b1 # homeassistant.components.media_player.liveboxplaytv liveboxplaytv==2.0.2 @@ -464,21 +514,24 @@ liveboxplaytv==2.0.2 # homeassistant.components.notify.lametric lmnotify==0.0.4 +# homeassistant.components.device_tracker.google_maps +locationsharinglib==2.0.7 + # homeassistant.components.sensor.luftdaten -luftdaten==0.1.3 +luftdaten==0.2.0 + +# homeassistant.components.light.lw12wifi +lw12==0.9.2 # homeassistant.components.sensor.lyft lyft_rides==0.2 -# homeassistant.components.notify.matrix -matrix-client==0.0.6 +# homeassistant.components.matrix +matrix-client==0.2.0 # homeassistant.components.maxcube maxcube-api==0.1.0 -# homeassistant.components.mercedesme -mercedesmejsonpy==0.1.2 - # homeassistant.components.notify.message_bird messagebird==1.2.0 @@ -487,10 +540,10 @@ messagebird==1.2.0 mficlient==0.3.0 # homeassistant.components.sensor.miflora -miflora==0.3.0 +miflora==0.4.0 -# homeassistant.components.upnp -miniupnpc==2.0.2 +# homeassistant.components.sensor.mitemp_bt +mitemp_bt==0.0.1 # homeassistant.components.sensor.mopar motorparts==1.0.2 @@ -499,7 +552,7 @@ motorparts==1.0.2 mutagen==1.40.0 # homeassistant.components.mychevy -mychevy==0.1.1 +mychevy==0.4.0 # homeassistant.components.mycroft mycroftapi==2.0 @@ -511,8 +564,14 @@ myusps==1.3.2 # homeassistant.components.media_player.nadtcp nad_receiver==0.0.9 +# homeassistant.components.light.nanoleaf_aurora +nanoleaf==0.4.1 + +# homeassistant.components.sensor.netdata +netdata==0.1.2 + # homeassistant.components.discovery -netdisco==1.3.0 +netdisco==1.4.1 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 @@ -525,7 +584,7 @@ nuheat==0.3.0 # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv -numpy==1.14.0 +numpy==1.14.3 # homeassistant.components.google oauth2client==4.0.0 @@ -565,9 +624,6 @@ pdunehd==1.3 # homeassistant.components.media_player.pandora pexpect==4.0.1 -# homeassistant.components.hue -phue==1.0 - # homeassistant.components.rpi_pfio pifacecommon==4.1.2 @@ -577,6 +633,9 @@ pifacedigitalio==3.0.5 # homeassistant.components.light.piglow piglow==1.2.4 +# homeassistant.components.sensor.pi_hole +pihole==0.1.2 + # homeassistant.components.pilight pilight==0.1.1 @@ -597,6 +656,9 @@ pmsensor==0.4 # homeassistant.components.sensor.pocketcasts pocketcasts==0.1 +# homeassistant.components.sensor.postnl +postnl_api==1.0.2 + # homeassistant.components.climate.proliphix proliphix==0.4.1 @@ -604,7 +666,7 @@ proliphix==0.4.1 prometheus_client==0.1.0 # homeassistant.components.sensor.systemmonitor -psutil==5.4.3 +psutil==5.4.5 # homeassistant.components.wink pubnubsub-handler==1.0.2 @@ -623,10 +685,10 @@ pwmled==1.2.1 py-august==0.4.0 # homeassistant.components.canary -py-canary==0.4.1 +py-canary==0.5.0 # homeassistant.components.sensor.cpuspeed -py-cpuinfo==3.3.0 +py-cpuinfo==4.0.0 # homeassistant.components.melissa py-melissa-climate==1.0.6 @@ -642,14 +704,17 @@ pyCEC==0.4.13 pyHS100==0.3.0 # homeassistant.components.rfxtrx -pyRFXtrx==0.21.1 +pyRFXtrx==0.22.1 # homeassistant.components.sensor.tibber -pyTibber==0.4.0 +pyTibber==0.4.1 # homeassistant.components.switch.dlink pyW215==0.6.0 +# homeassistant.components.cover.ryobi_gdo +py_ryobi_gdo==0.0.10 + # homeassistant.components.ads pyads==2.2.6 @@ -657,7 +722,7 @@ pyads==2.2.6 pyairvisual==1.0.0 # homeassistant.components.alarm_control_panel.alarmdotcom -pyalarmdotcom==0.3.1 +pyalarmdotcom==0.3.2 # homeassistant.components.arlo pyarlo==0.1.2 @@ -669,12 +734,15 @@ pyasn1-modules==0.1.5 pyasn1==0.3.7 # homeassistant.components.apple_tv -pyatv==0.3.9 +pyatv==0.3.10 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox pybbox==0.0.5-alpha +# homeassistant.components.media_player.blackbird +pyblackbird==0.5 + # homeassistant.components.device_tracker.bluetooth_tracker # pybluez==0.22 @@ -682,10 +750,10 @@ pybbox==0.0.5-alpha pychannels==1.0.0 # homeassistant.components.media_player.cast -pychromecast==2.0.0 +pychromecast==2.1.0 # homeassistant.components.media_player.cmus -pycmus==0.1.0 +pycmus==0.1.1 # homeassistant.components.comfoconnect pycomfoconnect==0.3 @@ -701,7 +769,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==31 +pydeconz==38 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -710,7 +778,7 @@ pydispatcher==2.0.5 pydroid-ipcam==0.8 # homeassistant.components.sensor.ebox -pyebox==0.1.0 +pyebox==1.1.4 # homeassistant.components.climate.econet pyeconet==0.0.5 @@ -719,7 +787,7 @@ pyeconet==0.0.5 pyedimax==0.1 # homeassistant.components.eight_sleep -pyeight==0.0.7 +pyeight==0.0.8 # homeassistant.components.media_player.emby pyemby==1.5 @@ -731,14 +799,23 @@ pyenvisalink==2.2 pyephember==0.1.1 # homeassistant.components.sensor.fido -pyfido==2.1.0 +pyfido==2.1.1 # homeassistant.components.climate.flexit pyflexit==0.3 +# homeassistant.components.fritzbox +pyfritzhome==0.3.7 + # homeassistant.components.ifttt pyfttt==0.3 +# homeassistant.components.sensor.skybeacon +pygatt==3.2.0 + +# homeassistant.components.cover.gogogate2 +pygogogate2==0.1.1 + # homeassistant.components.remote.harmony pyharmony==1.0.20 @@ -746,13 +823,13 @@ pyharmony==1.0.20 pyhik==0.1.8 # homeassistant.components.hive -pyhiveapi==0.2.11 +pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.39 +pyhomematic==0.1.43 # homeassistant.components.sensor.hydroquebec -pyhydroquebec==2.1.0 +pyhydroquebec==2.2.2 # homeassistant.components.alarm_control_panel.ialarm pyialarm==0.2 @@ -760,6 +837,9 @@ pyialarm==0.2 # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 +# homeassistant.components.weather.ipma +pyipma==1.1.3 + # homeassistant.components.sensor.irish_rail_transport pyirishrail==0.0.2 @@ -779,7 +859,7 @@ pykwb==0.0.8 pylacrosse==0.3.1 # homeassistant.components.sensor.lastfm -pylast==2.1.0 +pylast==2.2.0 # homeassistant.components.media_player.webostv # homeassistant.components.notify.webostv @@ -792,7 +872,7 @@ pylitejet==0.1 pyloopenergy==0.0.18 # homeassistant.components.lutron_caseta -pylutron-caseta==0.3.0 +pylutron-caseta==0.5.0 # homeassistant.components.lutron pylutron==0.1.0 @@ -801,7 +881,7 @@ pylutron==0.1.0 pymailgunner==1.4 # homeassistant.components.media_player.mediaroom -pymediaroom==0.5 +pymediaroom==0.6.3 # homeassistant.components.media_player.xiaomi_tv pymitv==1.0.0 @@ -822,13 +902,13 @@ pymusiccast==0.1.6 pymyq==0.0.8 # homeassistant.components.mysensors -pymysensors==0.11.1 +pymysensors==0.14.0 # homeassistant.components.lock.nello pynello==1.5.1 # homeassistant.components.device_tracker.netgear -pynetgear==0.3.3 +pynetgear==0.4.0 # homeassistant.components.switch.netio pynetio==0.1.6 @@ -844,7 +924,7 @@ pynut2==2.1.2 pynx584==0.4 # homeassistant.components.iota -pyota==2.0.4 +pyota==2.0.5 # homeassistant.components.sensor.otp pyotp==2.2.6 @@ -854,19 +934,19 @@ pyotp==2.2.6 pyowm==2.8.0 # homeassistant.components.sensor.pollen -pypollencom==1.1.1 +pypollencom==1.1.2 # homeassistant.components.qwikswitch -pyqwikswitch==0.4 +pyqwikswitch==0.8 # homeassistant.components.rainbird -pyrainbird==0.1.3 +pyrainbird==0.1.6 -# homeassistant.components.sensor.sabnzbd +# homeassistant.components.sabnzbd pysabnzbd==1.0.1 # homeassistant.components.climate.sensibo -pysensibo==1.0.2 +pysensibo==1.0.3 # homeassistant.components.sensor.serial pyserial-asyncio==0.4 @@ -888,8 +968,14 @@ pysma==0.2 # homeassistant.components.switch.snmp pysnmp==4.4.4 +# homeassistant.components.notify.stride +pystride==0.1.7 + +# homeassistant.components.sensor.syncthru +pysyncthru==0.3.1 + # homeassistant.components.media_player.liveboxplaytv -pyteleloisirs==3.3 +pyteleloisirs==3.4 # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner @@ -905,7 +991,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.13.2 # homeassistant.components.ecobee -python-ecobee-api==0.0.15 +python-ecobee-api==0.0.18 # homeassistant.components.climate.eq3btsmart # python-eq3bt==0.1.9 @@ -913,9 +999,12 @@ python-ecobee-api==0.0.15 # homeassistant.components.sensor.etherscan python-etherscan-api==0.0.3 +# homeassistant.components.camera.familyhub +python-family-hub-local==0.0.2 + # homeassistant.components.sensor.darksky # homeassistant.components.weather.darksky -python-forecastio==1.3.5 +python-forecastio==1.4.0 # homeassistant.components.gc100 python-gc100==1.0.3a @@ -933,22 +1022,24 @@ python-juicenet==0.0.5 # homeassistant.components.lirc # python-lirc==1.2.3 +# homeassistant.components.device_tracker.xiaomi_miio # homeassistant.components.fan.xiaomi_miio # homeassistant.components.light.xiaomi_miio # homeassistant.components.remote.xiaomi_miio +# homeassistant.components.sensor.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-miio==0.3.8 +python-miio==0.3.9 # homeassistant.components.media_player.mpd -python-mpd2==0.5.5 +python-mpd2==1.0.0 # homeassistant.components.light.mystrom # homeassistant.components.switch.mystrom -python-mystrom==0.3.8 +python-mystrom==0.4.2 # homeassistant.components.nest -python-nest==3.1.0 +python-nest==4.0.1 # homeassistant.components.device_tracker.nmap_tracker python-nmap==0.6.1 @@ -966,16 +1057,16 @@ python-roku==3.1.5 python-sochain-api==0.0.2 # homeassistant.components.media_player.songpal -python-songpal==0.0.6 +python-songpal==0.0.7 # homeassistant.components.sensor.synologydsm python-synology==0.1.0 # homeassistant.components.tado -python-tado==0.2.2 +python-tado==0.2.3 # homeassistant.components.telegram_bot -python-telegram-bot==9.0.0 +python-telegram-bot==10.1.0 # homeassistant.components.sensor.twitch python-twitch==1.3.0 @@ -990,14 +1081,13 @@ python-vlc==1.1.2 python-wink==1.7.3 # homeassistant.components.sensor.swiss_public_transport -python_opendata_transport==0.0.3 +python_opendata_transport==0.1.0 # homeassistant.components.zwave python_openzwave==0.4.3 # homeassistant.components.egardia -# homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.38 +pythonegardia==1.0.39 # homeassistant.components.sensor.whois pythonwhois==2.4.3 @@ -1012,19 +1102,25 @@ pytouchline==0.7 pytrackr==0.0.5 # homeassistant.components.tradfri -pytradfri[async]==4.1.0 +pytradfri[async]==5.4.2 # homeassistant.components.device_tracker.unifi pyunifi==2.13 +# homeassistant.components.upnp +pyupnp-async==0.1.0.2 + # homeassistant.components.keyboard # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.42 +pyvera==0.2.43 + +# homeassistant.components.switch.vesync +pyvesync==0.1.1 # homeassistant.components.media_player.vizio -pyvizio==0.0.2 +pyvizio==0.0.3 # homeassistant.components.velux pyvlx==0.1.3 @@ -1036,13 +1132,13 @@ pywebpush==1.6.0 pywemo==0.4.25 # homeassistant.components.camera.xeoma -pyxeoma==1.3 +pyxeoma==1.4.0 # homeassistant.components.zabbix pyzabbix==0.7.4 # homeassistant.components.sensor.qnap -qnapstats==0.2.4 +qnapstats==0.2.6 # homeassistant.components.switch.rachio rachiopy==0.1.2 @@ -1056,14 +1152,14 @@ raincloudy==0.0.4 # homeassistant.components.raspihats # raspihats==2.2.3 -# homeassistant.components.switch.rainmachine -regenmaschine==0.4.1 +# homeassistant.components.rainmachine +regenmaschine==0.4.2 # homeassistant.components.python_script -restrictedpython==4.0b2 +restrictedpython==4.0b4 # homeassistant.components.rflink -rflink==0.0.34 +rflink==0.0.37 # homeassistant.components.ring ring_doorbell==0.1.8 @@ -1112,7 +1208,7 @@ sense_energy==0.3.1 sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.7.7 +shodan==1.8.1 # homeassistant.components.notify.simplepush simplepush==1.1.4 @@ -1121,10 +1217,10 @@ simplepush==1.1.4 simplisafe-python==1.0.5 # homeassistant.components.skybell -skybellpy==0.1.1 +skybellpy==0.1.2 # homeassistant.components.notify.slack -slacker==0.9.60 +slacker==0.9.65 # homeassistant.components.notify.xmpp sleekxmpp==1.3.2 @@ -1146,19 +1242,22 @@ smappy==0.2.15 # homeassistant.components.media_player.snapcast snapcast==2.0.8 +# homeassistant.components.sensor.socialblade +socialbladeclient==0.2 + # homeassistant.components.climate.honeywell -somecomfort==0.5.0 +somecomfort==0.5.2 # homeassistant.components.sensor.speedtest -speedtest-cli==2.0.0 +speedtest-cli==2.0.2 # homeassistant.components.sensor.spotcrime -spotcrime==1.0.2 +spotcrime==1.0.3 # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.2 +sqlalchemy==1.2.8 # homeassistant.components.statsd statsd==3.2.1 @@ -1176,7 +1275,7 @@ tahoma-api==0.0.13 tank_utility==1.4.0 # homeassistant.components.binary_sensor.tapsaff -tapsaff==0.1.3 +tapsaff==0.2.0 # homeassistant.components.tellstick tellcore-net==0.4 @@ -1206,7 +1305,7 @@ todoist-python==7.0.17 toonlib==1.0.2 # homeassistant.components.alarm_control_panel.totalconnect -total_connect_client==0.16 +total_connect_client==0.18 # homeassistant.components.sensor.transmission # homeassistant.components.switch.transmission @@ -1224,6 +1323,9 @@ upcloud-api==0.4.2 # homeassistant.components.sensor.ups upsmychoice==1.0.6 +# homeassistant.components.sensor.uscis +uscisstatus==0.1.1 + # homeassistant.components.camera.uvc uvcclient==0.10.1 @@ -1257,6 +1359,9 @@ waqiasync==1.0.0 # homeassistant.components.cloud warrant==0.6.1 +# homeassistant.components.folder_watcher +watchdog==0.8.3 + # homeassistant.components.waterfurnace waterfurnace==0.4.0 @@ -1295,10 +1400,10 @@ yahooweather==0.10 yeelight==0.4.0 # homeassistant.components.light.yeelightsunflower -yeelightsunflower==0.0.8 +yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.03.10 +youtube_dl==2018.06.02 # homeassistant.components.light.zengge zengge==0.2 @@ -1310,7 +1415,7 @@ zeroconf==0.20.0 ziggo-mediabox-xl==1.0.0 # homeassistant.components.zha -zigpy-xbee==0.0.2 +zigpy-xbee==0.1.1 # homeassistant.components.zha -zigpy==0.0.3 +zigpy==0.1.0 diff --git a/requirements_docs.txt b/requirements_docs.txt index bb0d30462ce..0556b35fc08 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.7.1 -sphinx-autodoc-typehints==1.2.5 +Sphinx==1.7.5 +sphinx-autodoc-typehints==1.3.0 sphinx-autodoc-annotation==1.0.post1 diff --git a/requirements_test.txt b/requirements_test.txt index fc9e113e97c..0a4a0bcb5b0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,12 +6,12 @@ coveralls==1.2.0 flake8-docstrings==1.0.3 flake8==3.5 mock-open==1.3.1 -mypy==0.570 +mypy==0.590 pydocstyle==1.1.1 -pylint==1.8.2 +pylint==1.8.4 pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 pytest-timeout>=1.2.1 pytest==3.4.2 -requests_mock==1.4 +requests_mock==1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2c785bee3af..47f54954cd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,19 +7,19 @@ coveralls==1.2.0 flake8-docstrings==1.0.3 flake8==3.5 mock-open==1.3.1 -mypy==0.570 +mypy==0.590 pydocstyle==1.1.1 -pylint==1.8.2 +pylint==1.8.4 pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 pytest-timeout>=1.2.1 pytest==3.4.2 -requests_mock==1.4 +requests_mock==1.5 # homeassistant.components.homekit -HAP-python==1.1.7 +HAP-python==2.2.2 # homeassistant.components.notify.html5 PyJWT==1.6.0 @@ -32,10 +32,10 @@ aioautomatic==0.6.5 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp_cors==0.6.0 +aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==0.3.0 +aiohue==1.5.0 # homeassistant.components.notify.apns apns2==0.3.0 @@ -44,7 +44,7 @@ apns2==0.3.0 caldav==0.5.0 # homeassistant.components.sensor.coinmarketcap -coinmarketcap==4.2.1 +coinmarketcap==5.0.3 # homeassistant.components.device_tracker.upc_connect defusedxml==0.5.0 @@ -62,6 +62,9 @@ evohomeclient==0.2.5 # homeassistant.components.sensor.geo_rss_events feedparser==5.2.1 +# homeassistant.components.sensor.foobot +foobot_async==0.3.1 + # homeassistant.components.tts.google gTTS-token==1.1.1 @@ -72,13 +75,13 @@ ha-ffmpeg==1.9 haversine==0.4.5 # homeassistant.components.mqtt.server -hbmqtt==0.9.1 +hbmqtt==0.9.2 # homeassistant.components.binary_sensor.workday -holidays==0.9.4 +holidays==0.9.5 # homeassistant.components.frontend -home-assistant-frontend==20180310.0 +home-assistant-frontend==20180603.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -96,7 +99,7 @@ mficlient==0.3.0 # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv -numpy==1.14.0 +numpy==1.14.3 # homeassistant.components.mqtt # homeassistant.components.shiftr @@ -124,7 +127,13 @@ prometheus_client==0.1.0 pushbullet.py==0.11.0 # homeassistant.components.canary -py-canary==0.4.1 +py-canary==0.5.0 + +# homeassistant.components.media_player.blackbird +pyblackbird==0.5 + +# homeassistant.components.deconz +pydeconz==38 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -139,9 +148,12 @@ pymonoprice==0.3 # homeassistant.components.binary_sensor.nx584 pynx584==0.4 +# homeassistant.components.qwikswitch +pyqwikswitch==0.8 + # homeassistant.components.sensor.darksky # homeassistant.components.weather.darksky -python-forecastio==1.3.5 +python-forecastio==1.4.0 # homeassistant.components.sensor.whois pythonwhois==2.4.3 @@ -149,14 +161,17 @@ pythonwhois==2.4.3 # homeassistant.components.device_tracker.unifi pyunifi==2.13 +# homeassistant.components.upnp +pyupnp-async==0.1.0.2 + # homeassistant.components.notify.html5 pywebpush==1.6.0 # homeassistant.components.python_script -restrictedpython==4.0b2 +restrictedpython==4.0b4 # homeassistant.components.rflink -rflink==0.0.34 +rflink==0.0.37 # homeassistant.components.ring ring_doorbell==0.1.8 @@ -168,12 +183,12 @@ rxv==0.5.1 sleepyq==0.6 # homeassistant.components.climate.honeywell -somecomfort==0.5.0 +somecomfort==0.5.2 # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.2 +sqlalchemy==1.2.8 # homeassistant.components.statsd statsd==3.2.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a7704088e26..b5b636dc874 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -33,6 +33,7 @@ COMMENT_REQUIREMENTS = ( 'i2csense', 'credstash', 'bme680', + 'homekit', ) TEST_REQUIREMENTS = ( @@ -47,6 +48,7 @@ TEST_REQUIREMENTS = ( 'ephem', 'evohomeclient', 'feedparser', + 'foobot_async', 'gTTS-token', 'HAP-python', 'ha-ffmpeg', @@ -66,13 +68,17 @@ TEST_REQUIREMENTS = ( 'prometheus_client', 'pushbullet.py', 'py-canary', + 'pyblackbird', + 'pydeconz', 'pydispatcher', 'PyJWT', 'pylitejet', 'pymonoprice', 'pynx584', + 'pyqwikswitch', 'python-forecastio', 'pyunifi', + 'pyupnp-async', 'pywebpush', 'restrictedpython', 'rflink', diff --git a/script/lazytox.py b/script/lazytox.py index 2639d640753..19af5560dfb 100755 --- a/script/lazytox.py +++ b/script/lazytox.py @@ -18,7 +18,7 @@ except ImportError: RE_ASCII = re.compile(r"\033\[[^m]*m") -Error = namedtuple('Error', ['file', 'line', 'col', 'msg']) +Error = namedtuple('Error', ['file', 'line', 'col', 'msg', 'skip']) PASS = 'green' FAIL = 'bold_red' @@ -109,8 +109,9 @@ async def pylint(files): line = line.split(':') if len(line) < 3: continue - res.append(Error(line[0].replace('\\', '/'), - line[1], "", line[2].strip())) + _fn = line[0].replace('\\', '/') + res.append(Error( + _fn, line[1], '', line[2].strip(), _fn.startswith('tests/'))) return res @@ -122,8 +123,8 @@ async def flake8(files): line = line.split(':') if len(line) < 4: continue - res.append(Error(line[0].replace('\\', '/'), - line[1], line[2], line[3].strip())) + _fn = line[0].replace('\\', '/') + res.append(Error(_fn, line[1], line[2], line[3].strip(), False)) return res @@ -144,7 +145,7 @@ async def lint(files): err_msg = "{} {}:{} {}".format(err.file, err.line, err.col, err.msg) # tests/* does not have to pass lint - if err.file.startswith('tests/'): + if err.skip: print(err_msg) else: printc(FAIL, err_msg) diff --git a/script/lint b/script/lint index dc6884f4882..8ba14d8939e 100755 --- a/script/lint +++ b/script/lint @@ -8,7 +8,7 @@ echo '=================================================' echo '= FILES CHANGED =' echo '=================================================' if [ -z "$files" ] ; then - echo "No python file changed. Rather use: tox -e lint" + echo "No python file changed. Rather use: tox -e lint\n" exit fi printf "%s\n" $files @@ -19,5 +19,10 @@ flake8 --doctests $files echo "================" echo "LINT with pylint" echo "================" -pylint $(echo "$files" | grep -v '^tests.*') +pylint_files=$(echo "$files" | grep -v '^tests.*') +if [ -z "$pylint_files" ] ; then + echo "Only test files changed. Skipping\n" + exit +fi +pylint $pylint_files echo diff --git a/script/release b/script/release index 65a6339cedc..cf4f808377e 100755 --- a/script/release +++ b/script/release @@ -21,10 +21,12 @@ fi CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD` -if [ "$CURRENT_BRANCH" != "master" ] +if [ "$CURRENT_BRANCH" != "master" ] && [ "$CURRENT_BRANCH" != "rc" ] then - echo "You have to be on the master branch to release." + echo "You have to be on the master or rc branch to release." exit 1 fi -python3 setup.py sdist bdist_wheel upload +rm -rf dist +python3 setup.py sdist bdist_wheel +python3 -m twine upload dist/* --skip-existing diff --git a/script/translations_upload b/script/translations_upload index 578cc8c0ccf..5bf9fe1e121 100755 --- a/script/translations_upload +++ b/script/translations_upload @@ -35,9 +35,10 @@ script/translations_upload_merge.py docker run \ -v ${LOCAL_FILE}:/opt/src/${LOCAL_FILE} \ - lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 lokalise \ + lokalise/lokalise-cli@sha256:2198814ebddfda56ee041a4b427521757dd57f75415ea9693696a64c550cef21 lokalise \ --token ${LOKALISE_TOKEN} \ import ${PROJECT_ID} \ --file /opt/src/${LOCAL_FILE} \ --lang_iso ${LANG_ISO} \ + --convert_placeholders 0 \ --replace 1 diff --git a/script/version_bump.py b/script/version_bump.py new file mode 100755 index 00000000000..59060a7075b --- /dev/null +++ b/script/version_bump.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Helper script to bump the current version.""" +import argparse +import re + +from packaging.version import Version + +from homeassistant import const + + +def _bump_release(release, bump_type): + """Bump a release tuple consisting of 3 numbers.""" + major, minor, patch = release + + if bump_type == 'patch': + patch += 1 + elif bump_type == 'minor': + minor += 1 + patch = 0 + + return major, minor, patch + + +def bump_version(version, bump_type): + """Return a new version given a current version and action.""" + to_change = {} + + if bump_type == 'minor': + # Convert 0.67.3 to 0.68.0 + # Convert 0.67.3.b5 to 0.68.0 + # Convert 0.67.3.dev0 to 0.68.0 + # Convert 0.67.0.b5 to 0.67.0 + # Convert 0.67.0.dev0 to 0.67.0 + to_change['dev'] = None + to_change['pre'] = None + + if not version.is_prerelease or version.release[2] != 0: + to_change['release'] = _bump_release(version.release, 'minor') + + elif bump_type == 'patch': + # Convert 0.67.3 to 0.67.4 + # Convert 0.67.3.b5 to 0.67.3 + # Convert 0.67.3.dev0 to 0.67.3 + to_change['dev'] = None + to_change['pre'] = None + + if not version.is_prerelease: + to_change['release'] = _bump_release(version.release, 'patch') + + elif bump_type == 'dev': + # Convert 0.67.3 to 0.67.4.dev0 + # Convert 0.67.3.b5 to 0.67.4.dev0 + # Convert 0.67.3.dev0 to 0.67.3.dev1 + if version.is_devrelease: + to_change['dev'] = ('dev', version.dev + 1) + else: + to_change['pre'] = ('dev', 0) + to_change['release'] = _bump_release(version.release, 'minor') + + elif bump_type == 'beta': + # Convert 0.67.5 to 0.67.6b0 + # Convert 0.67.0.dev0 to 0.67.0b0 + # Convert 0.67.5.b4 to 0.67.5b5 + + if version.is_devrelease: + to_change['dev'] = None + to_change['pre'] = ('b', 0) + + elif version.is_prerelease: + if version.pre[0] == 'a': + to_change['pre'] = ('b', 0) + if version.pre[0] == 'b': + to_change['pre'] = ('b', version.pre[1] + 1) + else: + to_change['pre'] = ('b', 0) + to_change['release'] = _bump_release(version.release, 'patch') + + else: + to_change['release'] = _bump_release(version.release, 'patch') + to_change['pre'] = ('b', 0) + + else: + assert False, 'Unsupported type: {}'.format(bump_type) + + temp = Version('0') + temp._version = version._version._replace(**to_change) + return Version(str(temp)) + + +def write_version(version): + """Update Home Assistant constant file with new version.""" + with open('homeassistant/const.py') as fil: + content = fil.read() + + major, minor, patch = str(version).split('.', 2) + + content = re.sub('MAJOR_VERSION = .*\n', + 'MAJOR_VERSION = {}\n'.format(major), + content) + content = re.sub('MINOR_VERSION = .*\n', + 'MINOR_VERSION = {}\n'.format(minor), + content) + content = re.sub('PATCH_VERSION = .*\n', + "PATCH_VERSION = '{}'\n".format(patch), + content) + + with open('homeassistant/const.py', 'wt') as fil: + content = fil.write(content) + + +def main(): + """Execute script.""" + parser = argparse.ArgumentParser( + description="Bump version of Home Assistant") + parser.add_argument( + 'type', + help="The type of the bump the version to.", + choices=['beta', 'dev', 'patch', 'minor'], + ) + arguments = parser.parse_args() + current = Version(const.__version__) + bumped = bump_version(current, arguments.type) + assert bumped > current, 'BUG! New version is not newer than old version' + write_version(bumped) + + +def test_bump_version(): + """Make sure it all works.""" + assert bump_version(Version('0.56.0'), 'beta') == Version('0.56.1b0') + assert bump_version(Version('0.56.0b3'), 'beta') == Version('0.56.0b4') + assert bump_version(Version('0.56.0.dev0'), 'beta') == Version('0.56.0b0') + + assert bump_version(Version('0.56.3'), 'dev') == Version('0.57.0.dev0') + assert bump_version(Version('0.56.0b3'), 'dev') == Version('0.57.0.dev0') + assert bump_version(Version('0.56.0.dev0'), 'dev') == \ + Version('0.56.0.dev1') + + assert bump_version(Version('0.56.3'), 'patch') == \ + Version('0.56.4') + assert bump_version(Version('0.56.3.b3'), 'patch') == \ + Version('0.56.3') + assert bump_version(Version('0.56.0.dev0'), 'patch') == \ + Version('0.56.0') + + assert bump_version(Version('0.56.0'), 'minor') == \ + Version('0.57.0') + assert bump_version(Version('0.56.3'), 'minor') == \ + Version('0.57.0') + assert bump_version(Version('0.56.0.b3'), 'minor') == \ + Version('0.56.0') + assert bump_version(Version('0.56.3.b3'), 'minor') == \ + Version('0.57.0') + assert bump_version(Version('0.56.0.dev0'), 'minor') == \ + Version('0.56.0') + assert bump_version(Version('0.56.2.dev0'), 'minor') == \ + Version('0.57.0') + + +if __name__ == '__main__': + main() diff --git a/setup.cfg b/setup.cfg index d6dfdfe0ea5..8b17da455dc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,3 @@ -[wheel] -universal = 1 - [tool:pytest] testpaths = tests norecursedirs = .git testing_config diff --git a/setup.py b/setup.py index 2e44258c619..4390b980f9e 100755 --- a/setup.py +++ b/setup.py @@ -42,18 +42,18 @@ DOWNLOAD_URL = '{}/archive/{}.zip'.format(GITHUB_URL, hass_const.__version__) PACKAGES = find_packages(exclude=['tests', 'tests.*']) REQUIRES = [ - 'requests==2.18.4', - 'pyyaml>=3.11,<4', - 'pytz>=2017.02', - 'pip>=8.0.3', + 'aiohttp==3.2.1', + 'astral==1.6.1', + 'async_timeout==3.0.0', + 'attrs==18.1.0', + 'certifi>=2018.04.16', 'jinja2>=2.10', - 'voluptuous==0.11.1', + 'pip>=8.0.3', + 'pytz>=2018.04', + 'pyyaml>=3.11,<4', + 'requests==2.18.4', 'typing>=3,<4', - 'aiohttp==3.0.7', - 'async_timeout==2.0.0', - 'astral==1.6', - 'certifi>=2017.4.17', - 'attrs==17.4.0', + 'voluptuous==0.11.1', ] MIN_PY_VERSION = '.'.join(map(str, hass_const.REQUIRED_PYTHON_VER)) diff --git a/tests/auth_providers/__init__.py b/tests/auth_providers/__init__.py new file mode 100644 index 00000000000..dd1b58639b1 --- /dev/null +++ b/tests/auth_providers/__init__.py @@ -0,0 +1 @@ +"""Tests for the auth providers.""" diff --git a/tests/auth_providers/test_homeassistant.py b/tests/auth_providers/test_homeassistant.py new file mode 100644 index 00000000000..8b12e682865 --- /dev/null +++ b/tests/auth_providers/test_homeassistant.py @@ -0,0 +1,124 @@ +"""Test the Home Assistant local auth provider.""" +from unittest.mock import patch, mock_open + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.auth_providers import homeassistant as hass_auth + + +MOCK_PATH = '/bla/users.json' +JSON__OPEN_PATH = 'homeassistant.util.json.open' + + +def test_initialize_empty_config_file_not_found(): + """Test that we initialize an empty config.""" + with patch('homeassistant.util.json.open', side_effect=FileNotFoundError): + data = hass_auth.load_data(MOCK_PATH) + + assert data is not None + + +def test_adding_user(): + """Test adding a user.""" + data = hass_auth.Data(MOCK_PATH, None) + data.add_user('test-user', 'test-pass') + data.validate_login('test-user', 'test-pass') + + +def test_adding_user_duplicate_username(): + """Test adding a user.""" + data = hass_auth.Data(MOCK_PATH, None) + data.add_user('test-user', 'test-pass') + with pytest.raises(hass_auth.InvalidUser): + data.add_user('test-user', 'other-pass') + + +def test_validating_password_invalid_user(): + """Test validating an invalid user.""" + data = hass_auth.Data(MOCK_PATH, None) + + with pytest.raises(hass_auth.InvalidAuth): + data.validate_login('non-existing', 'pw') + + +def test_validating_password_invalid_password(): + """Test validating an invalid user.""" + data = hass_auth.Data(MOCK_PATH, None) + data.add_user('test-user', 'test-pass') + + with pytest.raises(hass_auth.InvalidAuth): + data.validate_login('test-user', 'invalid-pass') + + +def test_changing_password(): + """Test adding a user.""" + user = 'test-user' + data = hass_auth.Data(MOCK_PATH, None) + data.add_user(user, 'test-pass') + data.change_password(user, 'new-pass') + + with pytest.raises(hass_auth.InvalidAuth): + data.validate_login(user, 'test-pass') + + data.validate_login(user, 'new-pass') + + +def test_changing_password_raises_invalid_user(): + """Test that we initialize an empty config.""" + data = hass_auth.Data(MOCK_PATH, None) + + with pytest.raises(hass_auth.InvalidUser): + data.change_password('non-existing', 'pw') + + +async def test_login_flow_validates(hass): + """Test login flow.""" + data = hass_auth.Data(MOCK_PATH, None) + data.add_user('test-user', 'test-pass') + + provider = hass_auth.HassAuthProvider(hass, None, {}) + flow = hass_auth.LoginFlow(provider) + result = await flow.async_step_init() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + with patch.object(provider, '_auth_data', return_value=data): + result = await flow.async_step_init({ + 'username': 'incorrect-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' + + result = await flow.async_step_init({ + 'username': 'test-user', + 'password': 'incorrect-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' + + result = await flow.async_step_init({ + 'username': 'test-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_saving_loading(hass): + """Test saving and loading JSON.""" + data = hass_auth.Data(MOCK_PATH, None) + data.add_user('test-user', 'test-pass') + data.add_user('second-user', 'second-pass') + + with patch(JSON__OPEN_PATH, mock_open(), create=True) as mock_write: + await hass.async_add_job(data.save) + + # Mock open calls are: open file, context enter, write, context leave + written = mock_write.mock_calls[2][1][0] + + with patch('os.path.isfile', return_value=True), \ + patch(JSON__OPEN_PATH, mock_open(read_data=written), create=True): + await hass.async_add_job(hass_auth.load_data, MOCK_PATH) + + data.validate_login('test-user', 'test-pass') + data.validate_login('second-user', 'second-pass') diff --git a/tests/auth_providers/test_insecure_example.py b/tests/auth_providers/test_insecure_example.py new file mode 100644 index 00000000000..0b481f93099 --- /dev/null +++ b/tests/auth_providers/test_insecure_example.py @@ -0,0 +1,85 @@ +"""Tests for the insecure example auth provider.""" +from unittest.mock import Mock +import uuid + +import pytest + +from homeassistant import auth +from homeassistant.auth_providers import insecure_example + +from tests.common import mock_coro + + +@pytest.fixture +def store(): + """Mock store.""" + return auth.AuthStore(Mock()) + + +@pytest.fixture +def provider(store): + """Mock provider.""" + return insecure_example.ExampleAuthProvider(None, store, { + 'type': 'insecure_example', + 'users': [ + { + 'username': 'user-test', + 'password': 'password-test', + }, + { + 'username': '🎉', + 'password': '😎', + } + ] + }) + + +async def test_create_new_credential(provider): + """Test that we create a new credential.""" + credentials = await provider.async_get_or_create_credentials({ + 'username': 'user-test', + 'password': 'password-test', + }) + assert credentials.is_new is True + + +async def test_match_existing_credentials(store, provider): + """See if we match existing users.""" + existing = auth.Credentials( + id=uuid.uuid4(), + auth_provider_type='insecure_example', + auth_provider_id=None, + data={ + 'username': 'user-test' + }, + is_new=False, + ) + store.credentials_for_provider = Mock(return_value=mock_coro([existing])) + credentials = await provider.async_get_or_create_credentials({ + 'username': 'user-test', + 'password': 'password-test', + }) + assert credentials is existing + + +async def test_verify_username(provider): + """Test we raise if incorrect user specified.""" + with pytest.raises(insecure_example.InvalidAuthError): + await provider.async_validate_login( + 'non-existing-user', 'password-test') + + +async def test_verify_password(provider): + """Test we raise if incorrect user specified.""" + with pytest.raises(insecure_example.InvalidAuthError): + await provider.async_validate_login( + 'user-test', 'incorrect-password') + + +async def test_utf_8_username_password(provider): + """Test that we create a new credential.""" + credentials = await provider.async_get_or_create_credentials({ + 'username': '🎉', + 'password': '😎', + }) + assert credentials.is_new is True diff --git a/tests/common.py b/tests/common.py index bc84b3493a8..f53d1c2be2b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,7 +10,7 @@ import logging import threading from contextlib import contextmanager -from homeassistant import core as ha, loader, config_entries +from homeassistant import auth, core as ha, data_entry_flow, config_entries from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config from homeassistant.helpers import ( @@ -113,6 +113,9 @@ def async_test_home_assistant(loop): hass.config_entries = config_entries.ConfigEntries(hass, {}) hass.config_entries._entries = [] hass.config.async_load = Mock() + store = auth.AuthStore(hass) + hass.auth = auth.AuthManager(hass, store, {}) + ensure_auth_manager_loaded(hass.auth) INSTANCES.append(hass) orig_async_add_job = hass.async_add_job @@ -134,9 +137,6 @@ def async_test_home_assistant(loop): hass.config.units = METRIC_SYSTEM hass.config.skip_pip = True - if 'custom_components.test' not in loader.AVAILABLE_COMPONENTS: - yield from loop.run_in_executor(None, loader.prepare, hass) - hass.state = ha.CoreState.running # Mock async_start @@ -303,6 +303,34 @@ def mock_registry(hass, mock_entries=None): return registry +class MockUser(auth.User): + """Mock a user in Home Assistant.""" + + def __init__(self, id='mock-id', is_owner=True, is_active=True, + name='Mock User'): + """Initialize mock user.""" + super().__init__(id, is_owner, is_active, name) + + def add_to_hass(self, hass): + """Test helper to add entry to hass.""" + return self.add_to_auth_manager(hass.auth) + + def add_to_auth_manager(self, auth_mgr): + """Test helper to add entry to hass.""" + auth_mgr._store.users[self.id] = self + return self + + +@ha.callback +def ensure_auth_manager_loaded(auth_mgr): + """Ensure an auth manager is considered loaded.""" + store = auth_mgr._store + if store.clients is None: + store.clients = {} + if store.users is None: + store.users = {} + + class MockModule(object): """Representation of a fake module.""" @@ -344,7 +372,8 @@ class MockPlatform(object): # pylint: disable=invalid-name def __init__(self, setup_platform=None, dependencies=None, - platform_schema=None, async_setup_platform=None): + platform_schema=None, async_setup_platform=None, + async_setup_entry=None): """Initialize the platform.""" self.DEPENDENCIES = dependencies or [] @@ -358,6 +387,9 @@ class MockPlatform(object): if async_setup_platform is not None: self.async_setup_platform = async_setup_platform + if async_setup_entry is not None: + self.async_setup_entry = async_setup_entry + if setup_platform is None and async_setup_platform is None: self.async_setup_platform = mock_coro_func() @@ -370,19 +402,27 @@ class MockEntityPlatform(entity_platform.EntityPlatform): logger=None, domain='test_domain', platform_name='test_platform', + platform=None, scan_interval=timedelta(seconds=15), - parallel_updates=0, entity_namespace=None, async_entities_added_callback=lambda: None ): """Initialize a mock entity platform.""" + if logger is None: + logger = logging.getLogger('homeassistant.helpers.entity_platform') + + # Otherwise the constructor will blow up. + if (isinstance(platform, Mock) and + isinstance(platform.PARALLEL_UPDATES, Mock)): + platform.PARALLEL_UPDATES = 0 + super().__init__( hass=hass, logger=logger, domain=domain, platform_name=platform_name, + platform=platform, scan_interval=scan_interval, - parallel_updates=parallel_updates, entity_namespace=entity_namespace, async_entities_added_callback=async_entities_added_callback, ) @@ -443,7 +483,7 @@ class MockConfigEntry(config_entries.ConfigEntry): """Helper for creating config entries that adds some defaults.""" def __init__(self, *, domain='test', data=None, version=0, entry_id=None, - source=config_entries.SOURCE_USER, title='Mock Title', + source=data_entry_flow.SOURCE_USER, title='Mock Title', state=None): """Initialize a mock config entry.""" kwargs = { diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py index d9f0c8e156d..d7871e82afc 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -21,7 +21,7 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client(loop, hass, test_client): +def alexa_client(loop, hass, aiohttp_client): """Initialize a Home Assistant server for testing this module.""" @callback def mock_service(call): @@ -49,7 +49,7 @@ def alexa_client(loop, hass, test_client): }, } })) - return loop.run_until_complete(test_client(hass.http.app)) + return loop.run_until_complete(aiohttp_client(hass.http.app)) def _flash_briefing_req(client, briefing_id): diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index 2c8fafde155..d15c7ccbb34 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -23,7 +23,7 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client(loop, hass, test_client): +def alexa_client(loop, hass, aiohttp_client): """Initialize a Home Assistant server for testing this module.""" @callback def mock_service(call): @@ -95,7 +95,7 @@ def alexa_client(loop, hass, test_client): }, } })) - return loop.run_until_complete(test_client(hass.http.app)) + return loop.run_until_complete(aiohttp_client(hass.http.app)) def _intent_req(client, data=None): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 8de4d0d9aff..afa4d19b5d9 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -693,6 +693,141 @@ def test_unknown_sensor(hass): yield from discovery_test(device, hass, expected_endpoints=0) +async def test_thermostat(hass): + """Test thermostat discovery.""" + device = ( + 'climate.test_thermostat', + 'cool', + { + 'operation_mode': 'cool', + 'temperature': 70.0, + 'target_temp_high': 80.0, + 'target_temp_low': 60.0, + 'current_temperature': 75.0, + 'friendly_name': "Test Thermostat", + 'supported_features': 1 | 2 | 4 | 128, + 'operation_list': ['heat', 'cool', 'auto', 'off'], + 'min_temp': 50, + 'max_temp': 90, + 'unit_of_measurement': TEMP_FAHRENHEIT, + } + ) + appliance = await discovery_test(device, hass) + + assert appliance['endpointId'] == 'climate#test_thermostat' + assert appliance['displayCategories'][0] == 'THERMOSTAT' + assert appliance['friendlyName'] == "Test Thermostat" + + assert_endpoint_capabilities( + appliance, + 'Alexa.ThermostatController', + 'Alexa.TemperatureSensor', + ) + + properties = await reported_properties( + hass, 'climate#test_thermostat') + properties.assert_equal( + 'Alexa.ThermostatController', 'thermostatMode', 'COOL') + properties.assert_equal( + 'Alexa.ThermostatController', 'targetSetpoint', + {'value': 70.0, 'scale': 'FAHRENHEIT'}) + properties.assert_equal( + 'Alexa.TemperatureSensor', 'temperature', + {'value': 75.0, 'scale': 'FAHRENHEIT'}) + + call, _ = await assert_request_calls_service( + 'Alexa.ThermostatController', 'SetTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={'targetSetpoint': {'value': 69.0, 'scale': 'FAHRENHEIT'}} + ) + assert call.data['temperature'] == 69.0 + + msg = await assert_request_fails( + 'Alexa.ThermostatController', 'SetTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={'targetSetpoint': {'value': 0.0, 'scale': 'CELSIUS'}} + ) + assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE' + + call, _ = await assert_request_calls_service( + 'Alexa.ThermostatController', 'SetTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={ + 'targetSetpoint': {'value': 70.0, 'scale': 'FAHRENHEIT'}, + 'lowerSetpoint': {'value': 293.15, 'scale': 'KELVIN'}, + 'upperSetpoint': {'value': 30.0, 'scale': 'CELSIUS'}, + } + ) + assert call.data['temperature'] == 70.0 + assert call.data['target_temp_low'] == 68.0 + assert call.data['target_temp_high'] == 86.0 + + msg = await assert_request_fails( + 'Alexa.ThermostatController', 'SetTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={ + 'lowerSetpoint': {'value': 273.15, 'scale': 'KELVIN'}, + 'upperSetpoint': {'value': 75.0, 'scale': 'FAHRENHEIT'}, + } + ) + assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE' + + msg = await assert_request_fails( + 'Alexa.ThermostatController', 'SetTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={ + 'lowerSetpoint': {'value': 293.15, 'scale': 'FAHRENHEIT'}, + 'upperSetpoint': {'value': 75.0, 'scale': 'CELSIUS'}, + } + ) + assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE' + + call, _ = await assert_request_calls_service( + 'Alexa.ThermostatController', 'AdjustTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={'targetSetpointDelta': {'value': -10.0, 'scale': 'KELVIN'}} + ) + assert call.data['temperature'] == 52.0 + + msg = await assert_request_fails( + 'Alexa.ThermostatController', 'AdjustTargetTemperature', + 'climate#test_thermostat', 'climate.set_temperature', + hass, + payload={'targetSetpointDelta': {'value': 20.0, 'scale': 'CELSIUS'}} + ) + assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE' + + call, _ = await assert_request_calls_service( + 'Alexa.ThermostatController', 'SetThermostatMode', + 'climate#test_thermostat', 'climate.set_operation_mode', + hass, + payload={'thermostatMode': {'value': 'HEAT'}} + ) + assert call.data['operation_mode'] == 'heat' + + call, _ = await assert_request_calls_service( + 'Alexa.ThermostatController', 'SetThermostatMode', + 'climate#test_thermostat', 'climate.set_operation_mode', + hass, + payload={'thermostatMode': 'HEAT'} + ) + + assert call.data['operation_mode'] == 'heat' + msg = await assert_request_fails( + 'Alexa.ThermostatController', 'SetThermostatMode', + 'climate#test_thermostat', 'climate.set_operation_mode', + hass, + payload={'thermostatMode': {'value': 'INVALID'}} + ) + assert msg['event']['payload']['type'] == 'UNSUPPORTED_THERMOSTAT_MODE' + + @asyncio.coroutine def test_exclude_filters(hass): """Test exclusion filters.""" @@ -950,42 +1085,6 @@ def test_api_set_color_rgb(hass): assert msg['header']['name'] == 'Response' -@asyncio.coroutine -def test_api_set_color_xy(hass): - """Test api set color process.""" - request = get_new_request( - 'Alexa.ColorController', 'SetColor', 'light#test') - - # add payload - request['directive']['payload']['color'] = { - 'hue': '120', - 'saturation': '0.612', - 'brightness': '0.342', - } - - # setup test devices - hass.states.async_set( - 'light.test', 'off', { - 'friendly_name': "Test light", - 'supported_features': 64, - }) - - call_light = async_mock_service(hass, 'light', 'turn_on') - - msg = yield from smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request) - yield from hass.async_block_till_done() - - assert 'event' in msg - msg = msg['event'] - - assert len(call_light) == 1 - assert call_light[0].data['entity_id'] == 'light.test' - assert call_light[0].data['xy_color'] == (0.23, 0.585) - assert call_light[0].data['brightness'] == 18 - assert msg['header']['name'] == 'Response' - - @asyncio.coroutine def test_api_set_color_temperature(hass): """Test api set color temperature process.""" @@ -1199,10 +1298,10 @@ def test_unsupported_domain(hass): @asyncio.coroutine -def do_http_discovery(config, hass, test_client): +def do_http_discovery(config, hass, aiohttp_client): """Submit a request to the Smart Home HTTP API.""" yield from async_setup_component(hass, alexa.DOMAIN, config) - http_client = yield from test_client(hass.http.app) + http_client = yield from aiohttp_client(hass.http.app) request = get_new_request('Alexa.Discovery', 'Discover') response = yield from http_client.post( @@ -1213,7 +1312,7 @@ def do_http_discovery(config, hass, test_client): @asyncio.coroutine -def test_http_api(hass, test_client): +def test_http_api(hass, aiohttp_client): """With `smart_home:` HTTP API is exposed.""" config = { 'alexa': { @@ -1221,7 +1320,7 @@ def test_http_api(hass, test_client): } } - response = yield from do_http_discovery(config, hass, test_client) + response = yield from do_http_discovery(config, hass, aiohttp_client) response_data = yield from response.json() # Here we're testing just the HTTP view glue -- details of discovery are @@ -1230,12 +1329,12 @@ def test_http_api(hass, test_client): @asyncio.coroutine -def test_http_api_disabled(hass, test_client): +def test_http_api_disabled(hass, aiohttp_client): """Without `smart_home:`, the HTTP API is disabled.""" config = { 'alexa': {} } - response = yield from do_http_discovery(config, hass, test_client) + response = yield from do_http_discovery(config, hass, aiohttp_client) assert response.status == 404 diff --git a/tests/components/auth/__init__.py b/tests/components/auth/__init__.py new file mode 100644 index 00000000000..f0b205ff5ce --- /dev/null +++ b/tests/components/auth/__init__.py @@ -0,0 +1,40 @@ +"""Tests for the auth component.""" +from aiohttp.helpers import BasicAuth + +from homeassistant import auth +from homeassistant.setup import async_setup_component + +from tests.common import ensure_auth_manager_loaded + + +BASE_CONFIG = [{ + 'name': 'Example', + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name' + }] +}] +CLIENT_ID = 'test-id' +CLIENT_SECRET = 'test-secret' +CLIENT_AUTH = BasicAuth(CLIENT_ID, CLIENT_SECRET) +CLIENT_REDIRECT_URI = 'http://example.com/callback' + + +async def async_setup_auth(hass, aiohttp_client, provider_configs=BASE_CONFIG, + setup_api=False): + """Helper to setup authentication and create a HTTP client.""" + hass.auth = await auth.auth_manager_from_config(hass, provider_configs) + ensure_auth_manager_loaded(hass.auth) + await async_setup_component(hass, 'auth', { + 'http': { + 'api_password': 'bla' + } + }) + client = auth.Client('Test Client', CLIENT_ID, CLIENT_SECRET, + redirect_uris=[CLIENT_REDIRECT_URI]) + hass.auth._store.clients[client.id] = client + if setup_api: + await async_setup_component(hass, 'api', {}) + return await aiohttp_client(hass.http.app) diff --git a/tests/components/auth/test_client.py b/tests/components/auth/test_client.py new file mode 100644 index 00000000000..65ad22efae2 --- /dev/null +++ b/tests/components/auth/test_client.py @@ -0,0 +1,70 @@ +"""Tests for the client validator.""" +from aiohttp.helpers import BasicAuth +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components.auth.client import verify_client +from homeassistant.components.http.view import HomeAssistantView + +from . import async_setup_auth + + +@pytest.fixture +def mock_view(hass): + """Register a view that verifies client id/secret.""" + hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) + + clients = [] + + class ClientView(HomeAssistantView): + url = '/' + name = 'bla' + + @verify_client + async def get(self, request, client): + """Handle GET request.""" + clients.append(client) + + hass.http.register_view(ClientView) + return clients + + +async def test_verify_client(hass, aiohttp_client, mock_view): + """Test that verify client can extract client auth from a request.""" + http_client = await async_setup_auth(hass, aiohttp_client) + client = await hass.auth.async_create_client('Hello') + + resp = await http_client.get('/', auth=BasicAuth(client.id, client.secret)) + assert resp.status == 200 + assert mock_view[0] is client + + +async def test_verify_client_no_auth_header(hass, aiohttp_client, mock_view): + """Test that verify client will decline unknown client id.""" + http_client = await async_setup_auth(hass, aiohttp_client) + + resp = await http_client.get('/') + assert resp.status == 401 + assert mock_view == [] + + +async def test_verify_client_invalid_client_id(hass, aiohttp_client, + mock_view): + """Test that verify client will decline unknown client id.""" + http_client = await async_setup_auth(hass, aiohttp_client) + client = await hass.auth.async_create_client('Hello') + + resp = await http_client.get('/', auth=BasicAuth('invalid', client.secret)) + assert resp.status == 401 + assert mock_view == [] + + +async def test_verify_client_invalid_client_secret(hass, aiohttp_client, + mock_view): + """Test that verify client will decline incorrect client secret.""" + http_client = await async_setup_auth(hass, aiohttp_client) + client = await hass.auth.async_create_client('Hello') + + resp = await http_client.get('/', auth=BasicAuth(client.id, 'invalid')) + assert resp.status == 401 + assert mock_view == [] diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py new file mode 100644 index 00000000000..7cff04327b8 --- /dev/null +++ b/tests/components/auth/test_init.py @@ -0,0 +1,54 @@ +"""Integration tests for the auth component.""" +from . import async_setup_auth, CLIENT_AUTH, CLIENT_REDIRECT_URI + + +async def test_login_new_user_and_refresh_token(hass, aiohttp_client): + """Test logging in with new user and refreshing tokens.""" + client = await async_setup_auth(hass, aiohttp_client, setup_api=True) + resp = await client.post('/auth/login_flow', json={ + 'handler': ['insecure_example', None], + 'redirect_uri': CLIENT_REDIRECT_URI, + }, auth=CLIENT_AUTH) + assert resp.status == 200 + step = await resp.json() + + resp = await client.post( + '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'username': 'test-user', + 'password': 'test-pass', + }, auth=CLIENT_AUTH) + + assert resp.status == 200 + step = await resp.json() + code = step['result'] + + # Exchange code for tokens + resp = await client.post('/auth/token', data={ + 'grant_type': 'authorization_code', + 'code': code + }, auth=CLIENT_AUTH) + + assert resp.status == 200 + tokens = await resp.json() + + assert hass.auth.async_get_access_token(tokens['access_token']) is not None + + # Use refresh token to get more tokens. + resp = await client.post('/auth/token', data={ + 'grant_type': 'refresh_token', + 'refresh_token': tokens['refresh_token'] + }, auth=CLIENT_AUTH) + + assert resp.status == 200 + tokens = await resp.json() + assert 'refresh_token' not in tokens + assert hass.auth.async_get_access_token(tokens['access_token']) is not None + + # Test using access token to hit API. + resp = await client.get('/api/') + assert resp.status == 401 + + resp = await client.get('/api/', headers={ + 'authorization': 'Bearer {}'.format(tokens['access_token']) + }) + assert resp.status == 200 diff --git a/tests/components/auth/test_init_link_user.py b/tests/components/auth/test_init_link_user.py new file mode 100644 index 00000000000..853c002ba46 --- /dev/null +++ b/tests/components/auth/test_init_link_user.py @@ -0,0 +1,152 @@ +"""Tests for the link user flow.""" +from . import async_setup_auth, CLIENT_AUTH, CLIENT_ID, CLIENT_REDIRECT_URI + + +async def async_get_code(hass, aiohttp_client): + """Helper for link user tests that returns authorization code.""" + config = [{ + 'name': 'Example', + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name' + }] + }, { + 'name': 'Example', + 'id': '2nd auth', + 'type': 'insecure_example', + 'users': [{ + 'username': '2nd-user', + 'password': '2nd-pass', + 'name': '2nd Name' + }] + }] + client = await async_setup_auth(hass, aiohttp_client, config) + + resp = await client.post('/auth/login_flow', json={ + 'handler': ['insecure_example', None], + 'redirect_uri': CLIENT_REDIRECT_URI, + }, auth=CLIENT_AUTH) + assert resp.status == 200 + step = await resp.json() + + resp = await client.post( + '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'username': 'test-user', + 'password': 'test-pass', + }, auth=CLIENT_AUTH) + + assert resp.status == 200 + step = await resp.json() + code = step['result'] + + # Exchange code for tokens + resp = await client.post('/auth/token', data={ + 'grant_type': 'authorization_code', + 'code': code + }, auth=CLIENT_AUTH) + + assert resp.status == 200 + tokens = await resp.json() + + access_token = hass.auth.async_get_access_token(tokens['access_token']) + assert access_token is not None + user = access_token.refresh_token.user + assert len(user.credentials) == 1 + + # Now authenticate with the 2nd flow + resp = await client.post('/auth/login_flow', json={ + 'handler': ['insecure_example', '2nd auth'], + 'redirect_uri': CLIENT_REDIRECT_URI, + }, auth=CLIENT_AUTH) + assert resp.status == 200 + step = await resp.json() + + resp = await client.post( + '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'username': '2nd-user', + 'password': '2nd-pass', + }, auth=CLIENT_AUTH) + + assert resp.status == 200 + step = await resp.json() + + return { + 'user': user, + 'code': step['result'], + 'client': client, + 'tokens': tokens, + } + + +async def test_link_user(hass, aiohttp_client): + """Test linking a user to new credentials.""" + info = await async_get_code(hass, aiohttp_client) + client = info['client'] + code = info['code'] + tokens = info['tokens'] + + # Link user + resp = await client.post('/auth/link_user', json={ + 'client_id': CLIENT_ID, + 'code': code + }, headers={ + 'authorization': 'Bearer {}'.format(tokens['access_token']) + }) + + assert resp.status == 200 + assert len(info['user'].credentials) == 2 + + +async def test_link_user_invalid_client_id(hass, aiohttp_client): + """Test linking a user to new credentials.""" + info = await async_get_code(hass, aiohttp_client) + client = info['client'] + code = info['code'] + tokens = info['tokens'] + + # Link user + resp = await client.post('/auth/link_user', json={ + 'client_id': 'invalid', + 'code': code + }, headers={ + 'authorization': 'Bearer {}'.format(tokens['access_token']) + }) + + assert resp.status == 400 + assert len(info['user'].credentials) == 1 + + +async def test_link_user_invalid_code(hass, aiohttp_client): + """Test linking a user to new credentials.""" + info = await async_get_code(hass, aiohttp_client) + client = info['client'] + tokens = info['tokens'] + + # Link user + resp = await client.post('/auth/link_user', json={ + 'client_id': CLIENT_ID, + 'code': 'invalid' + }, headers={ + 'authorization': 'Bearer {}'.format(tokens['access_token']) + }) + + assert resp.status == 400 + assert len(info['user'].credentials) == 1 + + +async def test_link_user_invalid_auth(hass, aiohttp_client): + """Test linking a user to new credentials.""" + info = await async_get_code(hass, aiohttp_client) + client = info['client'] + code = info['code'] + + # Link user + resp = await client.post('/auth/link_user', json={ + 'client_id': CLIENT_ID, + 'code': code, + }, headers={'authorization': 'Bearer invalid'}) + + assert resp.status == 401 + assert len(info['user'].credentials) == 1 diff --git a/tests/components/auth/test_init_login_flow.py b/tests/components/auth/test_init_login_flow.py new file mode 100644 index 00000000000..ad39fba3997 --- /dev/null +++ b/tests/components/auth/test_init_login_flow.py @@ -0,0 +1,67 @@ +"""Tests for the login flow.""" +from aiohttp.helpers import BasicAuth + +from . import async_setup_auth, CLIENT_AUTH, CLIENT_REDIRECT_URI + + +async def test_fetch_auth_providers(hass, aiohttp_client): + """Test fetching auth providers.""" + client = await async_setup_auth(hass, aiohttp_client) + resp = await client.get('/auth/providers', auth=CLIENT_AUTH) + assert await resp.json() == [{ + 'name': 'Example', + 'type': 'insecure_example', + 'id': None + }] + + +async def test_fetch_auth_providers_require_valid_client(hass, aiohttp_client): + """Test fetching auth providers.""" + client = await async_setup_auth(hass, aiohttp_client) + resp = await client.get('/auth/providers', + auth=BasicAuth('invalid', 'bla')) + assert resp.status == 401 + + +async def test_cannot_get_flows_in_progress(hass, aiohttp_client): + """Test we cannot get flows in progress.""" + client = await async_setup_auth(hass, aiohttp_client, []) + resp = await client.get('/auth/login_flow') + assert resp.status == 405 + + +async def test_invalid_username_password(hass, aiohttp_client): + """Test we cannot get flows in progress.""" + client = await async_setup_auth(hass, aiohttp_client) + resp = await client.post('/auth/login_flow', json={ + 'handler': ['insecure_example', None], + 'redirect_uri': CLIENT_REDIRECT_URI + }, auth=CLIENT_AUTH) + assert resp.status == 200 + step = await resp.json() + + # Incorrect username + resp = await client.post( + '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'username': 'wrong-user', + 'password': 'test-pass', + }, auth=CLIENT_AUTH) + + assert resp.status == 200 + step = await resp.json() + + assert step['step_id'] == 'init' + assert step['errors']['base'] == 'invalid_auth' + + # Incorrect password + resp = await client.post( + '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'username': 'test-user', + 'password': 'wrong-pass', + }, auth=CLIENT_AUTH) + + assert resp.status == 200 + step = await resp.json() + + assert step['step_id'] == 'init' + assert step['errors']['base'] == 'invalid_auth' diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py index df9ab69e7e8..aea6e517e38 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -26,7 +26,7 @@ class TestAutomationEvent(unittest.TestCase): self.hass.services.register('test', 'automation', record_call) def tearDown(self): - """"Stop everything that was started.""" + """Stop everything that was started.""" self.hass.stop() def test_if_fires_on_event(self): diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 7a8c097a730..33f1a7aa704 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -207,6 +207,7 @@ class TestAutomation(unittest.TestCase): """Test triggers.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { + 'alias': 'test', 'trigger': [ { 'platform': 'event', @@ -228,7 +229,9 @@ class TestAutomation(unittest.TestCase): self.hass.block_till_done() assert len(self.calls) == 0 - self.hass.services.call('automation', 'trigger', blocking=True) + self.hass.services.call('automation', 'trigger', + {'entity_id': 'automation.test'}, + blocking=True) self.hass.block_till_done() assert len(self.calls) == 1 diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 63ca4b5cd1a..de453675a57 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -35,7 +35,7 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.stop() def test_if_fires_on_entity_change_below(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -62,7 +62,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_if_fires_on_entity_change_over_to_below(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" self.hass.states.set('test.entity', 11) self.hass.block_till_done() @@ -85,7 +85,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_if_fires_on_entities_change_over_to_below(self): - """"Test the firing with changed entities.""" + """Test the firing with changed entities.""" self.hass.states.set('test.entity_1', 11) self.hass.states.set('test.entity_2', 11) self.hass.block_till_done() @@ -115,7 +115,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(2, len(self.calls)) def test_if_not_fires_on_entity_change_below_to_below(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" self.hass.states.set('test.entity', 11) self.hass.block_till_done() @@ -148,7 +148,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_if_not_below_fires_on_entity_change_to_equal(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" self.hass.states.set('test.entity', 11) self.hass.block_till_done() @@ -171,7 +171,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(0, len(self.calls)) def test_if_fires_on_initial_entity_below(self): - """"Test the firing when starting with a match.""" + """Test the firing when starting with a match.""" self.hass.states.set('test.entity', 9) self.hass.block_till_done() @@ -194,7 +194,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_if_fires_on_initial_entity_above(self): - """"Test the firing when starting with a match.""" + """Test the firing when starting with a match.""" self.hass.states.set('test.entity', 11) self.hass.block_till_done() @@ -217,7 +217,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_if_fires_on_entity_change_above(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -236,7 +236,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_if_fires_on_entity_change_below_to_above(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" # set initial state self.hass.states.set('test.entity', 9) self.hass.block_till_done() @@ -260,7 +260,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_if_not_fires_on_entity_change_above_to_above(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" # set initial state self.hass.states.set('test.entity', 9) self.hass.block_till_done() @@ -289,7 +289,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_if_not_above_fires_on_entity_change_to_equal(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" # set initial state self.hass.states.set('test.entity', 9) self.hass.block_till_done() @@ -313,7 +313,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(0, len(self.calls)) def test_if_fires_on_entity_change_below_range(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -333,7 +333,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_if_fires_on_entity_change_below_above_range(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -353,7 +353,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(0, len(self.calls)) def test_if_fires_on_entity_change_over_to_below_range(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" self.hass.states.set('test.entity', 11) self.hass.block_till_done() @@ -377,7 +377,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_if_fires_on_entity_change_over_to_below_above_range(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" self.hass.states.set('test.entity', 11) self.hass.block_till_done() @@ -401,7 +401,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(0, len(self.calls)) def test_if_not_fires_if_entity_not_match(self): - """"Test if not fired with non matching entity.""" + """Test if not fired with non matching entity.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -420,7 +420,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(0, len(self.calls)) def test_if_fires_on_entity_change_below_with_attribute(self): - """"Test attributes change.""" + """Test attributes change.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -439,7 +439,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_if_not_fires_on_entity_change_not_below_with_attribute(self): - """"Test attributes.""" + """Test attributes.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -458,7 +458,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(0, len(self.calls)) def test_if_fires_on_attribute_change_with_attribute_below(self): - """"Test attributes change.""" + """Test attributes change.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -478,7 +478,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_if_not_fires_on_attribute_change_with_attribute_not_below(self): - """"Test attributes change.""" + """Test attributes change.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -498,7 +498,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(0, len(self.calls)) def test_if_not_fires_on_entity_change_with_attribute_below(self): - """"Test attributes change.""" + """Test attributes change.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -518,7 +518,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(0, len(self.calls)) def test_if_not_fires_on_entity_change_with_not_attribute_below(self): - """"Test attributes change.""" + """Test attributes change.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -538,7 +538,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(0, len(self.calls)) def test_fires_on_attr_change_with_attribute_below_and_multiple_attr(self): - """"Test attributes change.""" + """Test attributes change.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -559,7 +559,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_template_list(self): - """"Test template list.""" + """Test template list.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -581,7 +581,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(1, len(self.calls)) def test_template_string(self): - """"Test template string.""" + """Test template string.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -614,7 +614,7 @@ class TestAutomationNumericState(unittest.TestCase): self.calls[0].data['some']) def test_not_fires_on_attr_change_with_attr_not_below_multiple_attr(self): - """"Test if not fired changed attributes.""" + """Test if not fired changed attributes.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -635,7 +635,7 @@ class TestAutomationNumericState(unittest.TestCase): self.assertEqual(0, len(self.calls)) def test_if_action(self): - """"Test if action.""" + """Test if action.""" entity_id = 'domain.test_entity' assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { diff --git a/tests/components/binary_sensor/test_bayesian.py b/tests/components/binary_sensor/test_bayesian.py index 3b403c3702f..c3242e09e78 100644 --- a/tests/components/binary_sensor/test_bayesian.py +++ b/tests/components/binary_sensor/test_bayesian.py @@ -154,6 +154,37 @@ class TestBayesianBinarySensor(unittest.TestCase): assert state.state == 'off' + def test_threshold(self): + """Test sensor on probabilty threshold limits.""" + config = { + 'binary_sensor': { + 'name': + 'Test_Binary', + 'platform': + 'bayesian', + 'observations': [{ + 'platform': 'state', + 'entity_id': 'sensor.test_monitored', + 'to_state': 'on', + 'prob_given_true': 1.0, + }], + 'prior': + 0.5, + 'probability_threshold': + 1.0, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 'on') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertAlmostEqual(1.0, state.attributes.get('probability')) + + assert state.state == 'on' + def test_multiple_observations(self): """Test sensor with multiple observations of same entity.""" config = { diff --git a/tests/components/binary_sensor/test_deconz.py b/tests/components/binary_sensor/test_deconz.py new file mode 100644 index 00000000000..2e33e28fa57 --- /dev/null +++ b/tests/components/binary_sensor/test_deconz.py @@ -0,0 +1,93 @@ +"""deCONZ binary sensor platform tests.""" +from unittest.mock import Mock, patch + +from homeassistant import config_entries +from homeassistant.components import deconz +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import mock_coro + + +SENSOR = { + "1": { + "id": "Sensor 1 id", + "name": "Sensor 1 name", + "type": "ZHAPresence", + "state": {"presence": False}, + "config": {} + }, + "2": { + "id": "Sensor 2 id", + "name": "Sensor 2 name", + "type": "ZHATemperature", + "state": {"temperature": False}, + "config": {} + } +} + + +async def setup_bridge(hass, data, allow_clip_sensor=True): + """Load the deCONZ binary sensor platform.""" + from pydeconz import DeconzSession + loop = Mock() + session = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + bridge = DeconzSession(loop, session, **entry.data) + with patch('pydeconz.DeconzSession.async_get_state', + return_value=mock_coro(data)): + await bridge.async_load_parameters() + hass.data[deconz.DOMAIN] = bridge + hass.data[deconz.DATA_DECONZ_UNSUB] = [] + hass.data[deconz.DATA_DECONZ_ID] = {} + config_entry = config_entries.ConfigEntry( + 1, deconz.DOMAIN, 'Mock Title', + {'host': 'mock-host', 'allow_clip_sensor': allow_clip_sensor}, 'test') + await hass.config_entries.async_forward_entry_setup( + config_entry, 'binary_sensor') + # To flush out the service call to update the group + await hass.async_block_till_done() + + +async def test_no_binary_sensors(hass): + """Test that no sensors in deconz results in no sensor entities.""" + data = {} + await setup_bridge(hass, data) + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + assert len(hass.states.async_all()) == 0 + + +async def test_binary_sensors(hass): + """Test successful creation of binary sensor entities.""" + data = {"sensors": SENSOR} + await setup_bridge(hass, data) + assert "binary_sensor.sensor_1_name" in hass.data[deconz.DATA_DECONZ_ID] + assert "binary_sensor.sensor_2_name" not in \ + hass.data[deconz.DATA_DECONZ_ID] + assert len(hass.states.async_all()) == 1 + + +async def test_add_new_sensor(hass): + """Test successful creation of sensor entities.""" + data = {} + await setup_bridge(hass, data) + sensor = Mock() + sensor.name = 'name' + sensor.type = 'ZHAPresence' + sensor.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_sensor', [sensor]) + await hass.async_block_till_done() + assert "binary_sensor.name" in hass.data[deconz.DATA_DECONZ_ID] + + +async def test_do_not_allow_clip_sensor(hass): + """Test that clip sensors can be ignored.""" + data = {} + await setup_bridge(hass, data, allow_clip_sensor=False) + sensor = Mock() + sensor.name = 'name' + sensor.type = 'CLIPPresence' + sensor.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_sensor', [sensor]) + await hass.async_block_till_done() + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 diff --git a/tests/components/binary_sensor/test_nx584.py b/tests/components/binary_sensor/test_nx584.py index d94d887c641..4d1d85d30fb 100644 --- a/tests/components/binary_sensor/test_nx584.py +++ b/tests/components/binary_sensor/test_nx584.py @@ -113,7 +113,7 @@ class TestNX584SensorSetup(unittest.TestCase): self._test_assert_graceful_fail({}) def test_setup_version_too_old(self): - """"Test if version is too old.""" + """Test if version is too old.""" nx584_client.Client.return_value.get_version.return_value = '1.0' self._test_assert_graceful_fail({}) diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index 18c095f4bc1..62623a04f3c 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -31,7 +31,7 @@ class TestBinarySensorTemplate(unittest.TestCase): self.hass.stop() def test_setup(self): - """"Test the setup.""" + """Test the setup.""" config = { 'binary_sensor': { 'platform': 'template', @@ -49,7 +49,7 @@ class TestBinarySensorTemplate(unittest.TestCase): self.hass, 'binary_sensor', config) def test_setup_no_sensors(self): - """"Test setup with no sensors.""" + """Test setup with no sensors.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { @@ -58,7 +58,7 @@ class TestBinarySensorTemplate(unittest.TestCase): }) def test_setup_invalid_device(self): - """"Test the setup with invalid devices.""" + """Test the setup with invalid devices.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { @@ -70,7 +70,7 @@ class TestBinarySensorTemplate(unittest.TestCase): }) def test_setup_invalid_device_class(self): - """"Test setup with invalid sensor class.""" + """Test setup with invalid sensor class.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { @@ -85,7 +85,7 @@ class TestBinarySensorTemplate(unittest.TestCase): }) def test_setup_invalid_missing_template(self): - """"Test setup with invalid and missing template.""" + """Test setup with invalid and missing template.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { @@ -161,7 +161,7 @@ class TestBinarySensorTemplate(unittest.TestCase): assert state.attributes['entity_picture'] == '/local/sensor.png' def test_attributes(self): - """"Test the attributes.""" + """Test the attributes.""" vs = run_callback_threadsafe( self.hass.loop, template.BinarySensorTemplate, self.hass, 'parent', 'Parent', 'motion', @@ -182,7 +182,7 @@ class TestBinarySensorTemplate(unittest.TestCase): self.assertTrue(vs.is_on) def test_event(self): - """"Test the event.""" + """Test the event.""" config = { 'binary_sensor': { 'platform': 'template', @@ -214,7 +214,7 @@ class TestBinarySensorTemplate(unittest.TestCase): @mock.patch('homeassistant.helpers.template.Template.render') def test_update_template_error(self, mock_render): - """"Test the template update error.""" + """Test the template update error.""" vs = run_callback_threadsafe( self.hass.loop, template.BinarySensorTemplate, self.hass, 'parent', 'Parent', 'motion', diff --git a/tests/components/calendar/test_caldav.py b/tests/components/calendar/test_caldav.py index e44e5cfc1f0..11dd0cb9635 100644 --- a/tests/components/calendar/test_caldav.py +++ b/tests/components/calendar/test_caldav.py @@ -105,6 +105,20 @@ LOCATION:Hamburg DESCRIPTION:What a day END:VEVENT END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:7 +DTSTART;TZID=America/Los_Angeles:20171127T083000 +DTSTAMP:20180301T020053Z +DTEND;TZID=America/Los_Angeles:20171127T093000 +SUMMARY:Enjoy the sun +LOCATION:San Francisco +DESCRIPTION:Sunny day +END:VEVENT +END:VCALENDAR """ ] @@ -225,7 +239,7 @@ class TestComponentsWebDavCalendar(unittest.TestCase): }, _add_device) - @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30)) + @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 45)) def test_ongoing_event(self, mock_now): """Test that the ongoing event is returned.""" cal = caldav.WebDavCalendarEventDevice(self.hass, @@ -244,6 +258,44 @@ class TestComponentsWebDavCalendar(unittest.TestCase): "description": "Surprisingly rainy" }) + @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30)) + def test_just_ended_event(self, mock_now): + """Test that the next ongoing event is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar) + + self.assertEqual(cal.name, DEVICE_DATA["name"]) + self.assertEqual(cal.state, STATE_ON) + self.assertEqual(cal.device_state_attributes, { + "message": "This is a normal event", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 17:00:00", + "end_time": "2017-11-27 18:00:00", + "location": "Hamburg", + "description": "Surprisingly rainy" + }) + + @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 00)) + def test_ongoing_event_different_tz(self, mock_now): + """Test that the ongoing event with another timezone is returned.""" + cal = caldav.WebDavCalendarEventDevice(self.hass, + DEVICE_DATA, + self.calendar) + + self.assertEqual(cal.name, DEVICE_DATA["name"]) + self.assertEqual(cal.state, STATE_ON) + self.assertEqual(cal.device_state_attributes, { + "message": "Enjoy the sun", + "all_day": False, + "offset_reached": False, + "start_time": "2017-11-27 16:30:00", + "description": "Sunny day", + "end_time": "2017-11-27 17:30:00", + "location": "San Francisco" + }) + @patch('homeassistant.util.dt.now', return_value=_local_datetime(8, 30)) def test_ongoing_event_with_offset(self, mock_now): """Test that the offset is taken into account.""" diff --git a/tests/components/calendar/test_google.py b/tests/components/calendar/test_google.py index 62c8ea8854f..9f94ea9f44c 100644 --- a/tests/components/calendar/test_google.py +++ b/tests/components/calendar/test_google.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access import logging import unittest -from unittest.mock import patch +from unittest.mock import patch, Mock import pytest @@ -11,7 +11,7 @@ import homeassistant.components.calendar.google as calendar import homeassistant.util.dt as dt_util from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers.template import DATE_STR_FORMAT -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, MockDependency TEST_PLATFORM = {calendar_base.DOMAIN: {CONF_PLATFORM: 'test'}} @@ -421,3 +421,16 @@ class TestComponentsGoogleCalendar(unittest.TestCase): 'location': event['location'], 'description': event['description'] }) + + @MockDependency("httplib2") + def test_update_false(self, mock_httplib2): + """Test that the update returns False upon Error.""" + mock_service = Mock() + mock_service.get = Mock( + side_effect=mock_httplib2.ServerNotFoundError("unit test")) + + cal = calendar.GoogleCalendarEventDevice(self.hass, mock_service, None, + {'name': "test"}) + result = cal.data.update() + + self.assertFalse(result) diff --git a/tests/components/camera/test_generic.py b/tests/components/camera/test_generic.py index 84eaf107d70..01edca1e996 100644 --- a/tests/components/camera/test_generic.py +++ b/tests/components/camera/test_generic.py @@ -6,7 +6,7 @@ from homeassistant.setup import async_setup_component @asyncio.coroutine -def test_fetching_url(aioclient_mock, hass, test_client): +def test_fetching_url(aioclient_mock, hass, aiohttp_client): """Test that it fetches the given url.""" aioclient_mock.get('http://example.com', text='hello world') @@ -19,7 +19,7 @@ def test_fetching_url(aioclient_mock, hass, test_client): 'password': 'pass' }}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.get('/api/camera_proxy/camera.config_test') @@ -33,7 +33,7 @@ def test_fetching_url(aioclient_mock, hass, test_client): @asyncio.coroutine -def test_limit_refetch(aioclient_mock, hass, test_client): +def test_limit_refetch(aioclient_mock, hass, aiohttp_client): """Test that it fetches the given url.""" aioclient_mock.get('http://example.com/5a', text='hello world') aioclient_mock.get('http://example.com/10a', text='hello world') @@ -49,7 +49,7 @@ def test_limit_refetch(aioclient_mock, hass, test_client): 'limit_refetch_to_url_change': True, }}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.get('/api/camera_proxy/camera.config_test') @@ -94,7 +94,7 @@ def test_limit_refetch(aioclient_mock, hass, test_client): @asyncio.coroutine -def test_camera_content_type(aioclient_mock, hass, test_client): +def test_camera_content_type(aioclient_mock, hass, aiohttp_client): """Test generic camera with custom content_type.""" svg_image = '' urlsvg = 'https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg' @@ -113,7 +113,7 @@ def test_camera_content_type(aioclient_mock, hass, test_client): yield from async_setup_component(hass, 'camera', { 'camera': [cam_config_svg, cam_config_normal]}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp_1 = yield from client.get('/api/camera_proxy/camera.config_test_svg') assert aioclient_mock.call_count == 1 diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 465d6276ad5..d0f1425a595 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -1,18 +1,19 @@ """The tests for the camera component.""" import asyncio +import base64 from unittest.mock import patch, mock_open import pytest from homeassistant.setup import setup_component, async_setup_component from homeassistant.const import ATTR_ENTITY_PICTURE -import homeassistant.components.camera as camera -import homeassistant.components.http as http +from homeassistant.components import camera, http, websocket_api from homeassistant.exceptions import HomeAssistantError from homeassistant.util.async_ import run_coroutine_threadsafe from tests.common import ( - get_test_home_assistant, get_test_instance_port, assert_setup_component) + get_test_home_assistant, get_test_instance_port, assert_setup_component, + mock_coro) @pytest.fixture @@ -90,36 +91,32 @@ class TestGetImage(object): self.hass, 'camera.demo_camera'), self.hass.loop).result() assert mock_camera.called - assert image == b'Test' + assert image.content == b'Test' def test_get_image_without_exists_camera(self): """Try to get image without exists camera.""" - self.hass.states.remove('camera.demo_camera') - - with pytest.raises(HomeAssistantError): + with patch('homeassistant.helpers.entity_component.EntityComponent.' + 'get_entity', return_value=None), \ + pytest.raises(HomeAssistantError): run_coroutine_threadsafe(camera.async_get_image( self.hass, 'camera.demo_camera'), self.hass.loop).result() - def test_get_image_with_timeout(self, aioclient_mock): + def test_get_image_with_timeout(self): """Try to get image with timeout.""" - aioclient_mock.get(self.url, exc=asyncio.TimeoutError()) - - with pytest.raises(HomeAssistantError): + with patch('homeassistant.components.camera.Camera.async_camera_image', + side_effect=asyncio.TimeoutError), \ + pytest.raises(HomeAssistantError): run_coroutine_threadsafe(camera.async_get_image( self.hass, 'camera.demo_camera'), self.hass.loop).result() - assert len(aioclient_mock.mock_calls) == 1 - - def test_get_image_with_bad_http_state(self, aioclient_mock): - """Try to get image with bad http status.""" - aioclient_mock.get(self.url, status=400) - - with pytest.raises(HomeAssistantError): + def test_get_image_fails(self): + """Try to get image with timeout.""" + with patch('homeassistant.components.camera.Camera.async_camera_image', + return_value=mock_coro(None)), \ + pytest.raises(HomeAssistantError): run_coroutine_threadsafe(camera.async_get_image( self.hass, 'camera.demo_camera'), self.hass.loop).result() - assert len(aioclient_mock.mock_calls) == 1 - @asyncio.coroutine def test_snapshot_service(hass, mock_camera): @@ -136,3 +133,24 @@ def test_snapshot_service(hass, mock_camera): assert len(mock_write.mock_calls) == 1 assert mock_write.mock_calls[0][1][0] == b'Test' + + +async def test_webocket_camera_thumbnail(hass, hass_ws_client, mock_camera): + """Test camera_thumbnail websocket command.""" + await async_setup_component(hass, 'camera') + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'camera_thumbnail', + 'entity_id': 'camera.demo_camera', + }) + + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == websocket_api.TYPE_RESULT + assert msg['success'] + assert msg['result']['content_type'] == 'image/jpeg' + assert msg['result']['content'] == \ + base64.b64encode(b'Test').decode('utf-8') diff --git a/tests/components/camera/test_local_file.py b/tests/components/camera/test_local_file.py index 42ce7bd7add..0a57512aabd 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -2,17 +2,16 @@ import asyncio from unittest import mock -# Using third party package because of a bug reading binary data in Python 3.4 -# https://bugs.python.org/issue23004 -from mock_open import MockOpen - +from homeassistant.components.camera import DOMAIN +from homeassistant.components.camera.local_file import ( + SERVICE_UPDATE_FILE_PATH) from homeassistant.setup import async_setup_component from tests.common import mock_registry @asyncio.coroutine -def test_loading_file(hass, test_client): +def test_loading_file(hass, aiohttp_client): """Test that it loads image from disk.""" mock_registry(hass) @@ -25,9 +24,9 @@ def test_loading_file(hass, test_client): 'file_path': 'mock.file', }}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) - m_open = MockOpen(read_data=b'hello') + m_open = mock.mock_open(read_data=b'hello') with mock.patch( 'homeassistant.components.camera.local_file.open', m_open, create=True @@ -57,7 +56,7 @@ def test_file_not_readable(hass, caplog): @asyncio.coroutine -def test_camera_content_type(hass, test_client): +def test_camera_content_type(hass, aiohttp_client): """Test local_file camera content_type.""" cam_config_jpg = { 'name': 'test_jpg', @@ -84,10 +83,10 @@ def test_camera_content_type(hass, test_client): 'camera': [cam_config_jpg, cam_config_png, cam_config_svg, cam_config_noext]}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) image = 'hello' - m_open = MockOpen(read_data=image.encode()) + m_open = mock.mock_open(read_data=image.encode()) with mock.patch('homeassistant.components.camera.local_file.open', m_open, create=True): resp_1 = yield from client.get('/api/camera_proxy/camera.test_jpg') @@ -115,3 +114,37 @@ def test_camera_content_type(hass, test_client): assert resp_4.content_type == 'image/jpeg' body = yield from resp_4.text() assert body == image + + +async def test_update_file_path(hass): + """Test update_file_path service.""" + # Setup platform + + mock_registry(hass) + + with mock.patch('os.path.isfile', mock.Mock(return_value=True)), \ + mock.patch('os.access', mock.Mock(return_value=True)): + await async_setup_component(hass, 'camera', { + 'camera': { + 'platform': 'local_file', + 'file_path': 'mock/path.jpg' + } + }) + + # Fetch state and check motion detection attribute + state = hass.states.get('camera.local_file') + assert state.attributes.get('friendly_name') == 'Local File' + assert state.attributes.get('file_path') == 'mock/path.jpg' + + service_data = { + "entity_id": 'camera.local_file', + "file_path": 'new/path.jpg' + } + + await hass.services.async_call(DOMAIN, + SERVICE_UPDATE_FILE_PATH, + service_data) + await hass.async_block_till_done() + + state = hass.states.get('camera.local_file') + assert state.attributes.get('file_path') == 'new/path.jpg' diff --git a/tests/components/camera/test_mqtt.py b/tests/components/camera/test_mqtt.py index 20d15efd982..d83054d7732 100644 --- a/tests/components/camera/test_mqtt.py +++ b/tests/components/camera/test_mqtt.py @@ -8,7 +8,7 @@ from tests.common import ( @asyncio.coroutine -def test_run_camera_setup(hass, test_client): +def test_run_camera_setup(hass, aiohttp_client): """Test that it fetches the given payload.""" topic = 'test/camera' yield from async_mock_mqtt_component(hass) @@ -24,7 +24,7 @@ def test_run_camera_setup(hass, test_client): async_fire_mqtt_message(hass, topic, 'beer') yield from hass.async_block_till_done() - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.get(url) assert resp.status == 200 body = yield from resp.text() diff --git a/tests/components/camera/test_uvc.py b/tests/components/camera/test_uvc.py index 40b4fb2d8e2..dabad953bea 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -26,7 +26,7 @@ class TestUVCSetup(unittest.TestCase): @mock.patch('uvcclient.nvr.UVCRemote') @mock.patch.object(uvc, 'UnifiVideoCamera') def test_setup_full_config(self, mock_uvc, mock_remote): - """"Test the setup with full configuration.""" + """Test the setup with full configuration.""" config = { 'platform': 'uvc', 'nvr': 'foo', @@ -41,7 +41,7 @@ class TestUVCSetup(unittest.TestCase): ] def fake_get_camera(uuid): - """"Create a fake camera.""" + """Create a fake camera.""" if uuid == 'id3': return {'model': 'airCam'} else: @@ -65,7 +65,7 @@ class TestUVCSetup(unittest.TestCase): @mock.patch('uvcclient.nvr.UVCRemote') @mock.patch.object(uvc, 'UnifiVideoCamera') def test_setup_partial_config(self, mock_uvc, mock_remote): - """"Test the setup with partial configuration.""" + """Test the setup with partial configuration.""" config = { 'platform': 'uvc', 'nvr': 'foo', @@ -152,7 +152,7 @@ class TestUVC(unittest.TestCase): """Test class for UVC.""" def setup_method(self, method): - """"Setup the mock camera.""" + """Setup the mock camera.""" self.nvr = mock.MagicMock() self.uuid = 'uuid' self.name = 'name' @@ -171,7 +171,7 @@ class TestUVC(unittest.TestCase): self.nvr.server_version = (3, 2, 0) def test_properties(self): - """"Test the properties.""" + """Test the properties.""" self.assertEqual(self.name, self.uvc.name) self.assertTrue(self.uvc.is_recording) self.assertEqual('Ubiquiti', self.uvc.brand) @@ -180,7 +180,7 @@ class TestUVC(unittest.TestCase): @mock.patch('uvcclient.store.get_info_store') @mock.patch('uvcclient.camera.UVCCameraClientV320') def test_login(self, mock_camera, mock_store): - """"Test the login.""" + """Test the login.""" self.uvc._login() self.assertEqual(mock_camera.call_count, 1) self.assertEqual( @@ -205,7 +205,7 @@ class TestUVC(unittest.TestCase): @mock.patch('uvcclient.store.get_info_store') @mock.patch('uvcclient.camera.UVCCameraClientV320') def test_login_tries_both_addrs_and_caches(self, mock_camera, mock_store): - """"Test the login tries.""" + """Test the login tries.""" responses = [0] def fake_login(*a): @@ -234,13 +234,13 @@ class TestUVC(unittest.TestCase): @mock.patch('uvcclient.store.get_info_store') @mock.patch('uvcclient.camera.UVCCameraClientV320') def test_login_fails_both_properly(self, mock_camera, mock_store): - """"Test if login fails properly.""" + """Test if login fails properly.""" mock_camera.return_value.login.side_effect = socket.error self.assertEqual(None, self.uvc._login()) self.assertEqual(None, self.uvc._connect_addr) def test_camera_image_tries_login_bails_on_failure(self): - """"Test retrieving failure.""" + """Test retrieving failure.""" with mock.patch.object(self.uvc, '_login') as mock_login: mock_login.return_value = False self.assertEqual(None, self.uvc.camera_image()) @@ -248,19 +248,19 @@ class TestUVC(unittest.TestCase): self.assertEqual(mock_login.call_args, mock.call()) def test_camera_image_logged_in(self): - """"Test the login state.""" + """Test the login state.""" self.uvc._camera = mock.MagicMock() self.assertEqual(self.uvc._camera.get_snapshot.return_value, self.uvc.camera_image()) def test_camera_image_error(self): - """"Test the camera image error.""" + """Test the camera image error.""" self.uvc._camera = mock.MagicMock() self.uvc._camera.get_snapshot.side_effect = camera.CameraConnectError self.assertEqual(None, self.uvc.camera_image()) def test_camera_image_reauths(self): - """"Test the re-authentication.""" + """Test the re-authentication.""" responses = [0] def fake_snapshot(): @@ -281,7 +281,7 @@ class TestUVC(unittest.TestCase): self.assertEqual([], responses) def test_camera_image_reauths_only_once(self): - """"Test if the re-authentication only happens once.""" + """Test if the re-authentication only happens once.""" self.uvc._camera = mock.MagicMock() self.uvc._camera.get_snapshot.side_effect = camera.CameraAuthError with mock.patch.object(self.uvc, '_login') as mock_login: diff --git a/tests/components/climate/test_ecobee.py b/tests/components/climate/test_ecobee.py index 4732376fceb..eb843d8eb34 100644 --- a/tests/components/climate/test_ecobee.py +++ b/tests/components/climate/test_ecobee.py @@ -3,6 +3,7 @@ import unittest from unittest import mock import homeassistant.const as const import homeassistant.components.climate.ecobee as ecobee +from homeassistant.components.climate import STATE_OFF class TestEcobee(unittest.TestCase): @@ -23,6 +24,7 @@ class TestEcobee(unittest.TestCase): 'desiredFanMode': 'on'}, 'settings': {'hvacMode': 'auto', 'fanMinOnTime': 10, + 'heatCoolMinDelta': 50, 'holdAction': 'nextTransition'}, 'equipmentStatus': 'fan', 'events': [{'name': 'Event1', @@ -81,17 +83,17 @@ class TestEcobee(unittest.TestCase): def test_desired_fan_mode(self): """Test desired fan mode property.""" - self.assertEqual('on', self.thermostat.desired_fan_mode) + self.assertEqual('on', self.thermostat.current_fan_mode) self.ecobee['runtime']['desiredFanMode'] = 'auto' - self.assertEqual('auto', self.thermostat.desired_fan_mode) + self.assertEqual('auto', self.thermostat.current_fan_mode) def test_fan(self): """Test fan property.""" self.assertEqual(const.STATE_ON, self.thermostat.fan) self.ecobee['equipmentStatus'] = '' - self.assertEqual(const.STATE_OFF, self.thermostat.fan) + self.assertEqual(STATE_OFF, self.thermostat.fan) self.ecobee['equipmentStatus'] = 'heatPump, heatPump2' - self.assertEqual(const.STATE_OFF, self.thermostat.fan) + self.assertEqual(STATE_OFF, self.thermostat.fan) def test_current_hold_mode_away_temporary(self): """Test current hold mode when away.""" @@ -180,7 +182,7 @@ class TestEcobee(unittest.TestCase): 'climate_list': ['Climate1', 'Climate2'], 'fan': 'off', 'fan_min_on_time': 10, - 'mode': 'Climate1', + 'climate_mode': 'Climate1', 'operation': 'heat'}, self.thermostat.device_state_attributes) @@ -189,7 +191,7 @@ class TestEcobee(unittest.TestCase): 'climate_list': ['Climate1', 'Climate2'], 'fan': 'off', 'fan_min_on_time': 10, - 'mode': 'Climate1', + 'climate_mode': 'Climate1', 'operation': 'heat'}, self.thermostat.device_state_attributes) self.ecobee['equipmentStatus'] = 'compCool1' @@ -197,7 +199,7 @@ class TestEcobee(unittest.TestCase): 'climate_list': ['Climate1', 'Climate2'], 'fan': 'off', 'fan_min_on_time': 10, - 'mode': 'Climate1', + 'climate_mode': 'Climate1', 'operation': 'cool'}, self.thermostat.device_state_attributes) self.ecobee['equipmentStatus'] = '' @@ -205,7 +207,7 @@ class TestEcobee(unittest.TestCase): 'climate_list': ['Climate1', 'Climate2'], 'fan': 'off', 'fan_min_on_time': 10, - 'mode': 'Climate1', + 'climate_mode': 'Climate1', 'operation': 'idle'}, self.thermostat.device_state_attributes) @@ -214,7 +216,7 @@ class TestEcobee(unittest.TestCase): 'climate_list': ['Climate1', 'Climate2'], 'fan': 'off', 'fan_min_on_time': 10, - 'mode': 'Climate1', + 'climate_mode': 'Climate1', 'operation': 'Unknown'}, self.thermostat.device_state_attributes) @@ -321,7 +323,7 @@ class TestEcobee(unittest.TestCase): self.assertFalse(self.data.ecobee.delete_vacation.called) self.assertFalse(self.data.ecobee.resume_program.called) self.data.ecobee.set_hold_temp.assert_has_calls( - [mock.call(1, 40.0, 20.0, 'nextTransition')]) + [mock.call(1, 35.0, 25.0, 'nextTransition')]) self.assertFalse(self.data.ecobee.set_climate_hold.called) def test_set_auto_temp_hold(self): @@ -337,21 +339,21 @@ class TestEcobee(unittest.TestCase): self.data.reset_mock() self.thermostat.set_temp_hold(30.0) self.data.ecobee.set_hold_temp.assert_has_calls( - [mock.call(1, 40.0, 20.0, 'nextTransition')]) + [mock.call(1, 35.0, 25.0, 'nextTransition')]) # Heat mode self.data.reset_mock() self.ecobee['settings']['hvacMode'] = 'heat' self.thermostat.set_temp_hold(30) self.data.ecobee.set_hold_temp.assert_has_calls( - [mock.call(1, 50, 30, 'nextTransition')]) + [mock.call(1, 30, 30, 'nextTransition')]) # Cool mode self.data.reset_mock() self.ecobee['settings']['hvacMode'] = 'cool' self.thermostat.set_temp_hold(30) self.data.ecobee.set_hold_temp.assert_has_calls( - [mock.call(1, 30, 10, 'nextTransition')]) + [mock.call(1, 30, 30, 'nextTransition')]) def test_set_temperature(self): """Test set temperature.""" @@ -366,21 +368,21 @@ class TestEcobee(unittest.TestCase): self.data.reset_mock() self.thermostat.set_temperature(temperature=20) self.data.ecobee.set_hold_temp.assert_has_calls( - [mock.call(1, 30, 10, 'nextTransition')]) + [mock.call(1, 25, 15, 'nextTransition')]) # Cool -> Hold self.data.reset_mock() self.ecobee['settings']['hvacMode'] = 'cool' self.thermostat.set_temperature(temperature=20.5) self.data.ecobee.set_hold_temp.assert_has_calls( - [mock.call(1, 20.5, 0.5, 'nextTransition')]) + [mock.call(1, 20.5, 20.5, 'nextTransition')]) # Heat -> Hold self.data.reset_mock() self.ecobee['settings']['hvacMode'] = 'heat' self.thermostat.set_temperature(temperature=20) self.data.ecobee.set_hold_temp.assert_has_calls( - [mock.call(1, 40, 20, 'nextTransition')]) + [mock.call(1, 20, 20, 'nextTransition')]) # Heat -> Auto self.data.reset_mock() @@ -450,3 +452,17 @@ class TestEcobee(unittest.TestCase): """Test climate list property.""" self.assertEqual(['Climate1', 'Climate2'], self.thermostat.climate_list) + + def test_set_fan_mode_on(self): + """Test set fan mode to on.""" + self.data.reset_mock() + self.thermostat.set_fan_mode('on') + self.data.ecobee.set_fan_mode.assert_has_calls( + [mock.call(1, 'on', 20, 40, 'nextTransition')]) + + def test_set_fan_mode_auto(self): + """Test set fan mode to auto.""" + self.data.reset_mock() + self.thermostat.set_fan_mode('auto') + self.data.ecobee.set_fan_mode.assert_has_calls( + [mock.call(1, 'auto', 20, 40, 'nextTransition')]) diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index bd0b764c6fe..7bc0b0a18e7 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -116,7 +116,7 @@ class TestGenericThermostatHeaterSwitching(unittest.TestCase): def test_heater_switch(self): """Test heater switching test switch.""" - platform = loader.get_component('switch.test') + platform = loader.get_component(self.hass, 'switch.test') platform.init() self.switch_1 = platform.DEVICES[1] assert setup_component(self.hass, switch.DOMAIN, {'switch': { diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 98ddebb5db3..55c6290c158 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -11,8 +11,11 @@ from homeassistant.components.cloud import DOMAIN, auth_api, iot from tests.common import mock_coro +GOOGLE_ACTIONS_SYNC_URL = 'https://api-test.hass.io/google_actions_sync' + + @pytest.fixture -def cloud_client(hass, test_client): +def cloud_client(hass, aiohttp_client): """Fixture that can fetch from the cloud client.""" with patch('homeassistant.components.cloud.Cloud.async_start', return_value=mock_coro()): @@ -23,12 +26,13 @@ def cloud_client(hass, test_client): 'user_pool_id': 'user_pool_id', 'region': 'region', 'relayer': 'relayer', + 'google_actions_sync_url': GOOGLE_ACTIONS_SYNC_URL, } })) hass.data['cloud']._decode_claims = \ lambda token: jwt.get_unverified_claims(token) with patch('homeassistant.components.cloud.Cloud.write_user_info'): - yield hass.loop.run_until_complete(test_client(hass.http.app)) + yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @pytest.fixture @@ -38,6 +42,21 @@ def mock_cognito(): yield mock_cog() +async def test_google_actions_sync(mock_cognito, cloud_client, aioclient_mock): + """Test syncing Google Actions.""" + aioclient_mock.post(GOOGLE_ACTIONS_SYNC_URL) + req = await cloud_client.post('/api/cloud/google_actions/sync') + assert req.status == 200 + + +async def test_google_actions_sync_fails(mock_cognito, cloud_client, + aioclient_mock): + """Test syncing Google Actions gone bad.""" + aioclient_mock.post(GOOGLE_ACTIONS_SYNC_URL, status=403) + req = await cloud_client.post('/api/cloud/google_actions/sync') + assert req.status == 403 + + @asyncio.coroutine def test_account_view_no_account(cloud_client): """Test fetching account if no account available.""" diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 70990519a0b..91f8ab8316d 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -29,6 +29,7 @@ def test_constructor_loads_info_from_constant(): 'user_pool_id': 'test-user_pool_id', 'region': 'test-region', 'relayer': 'test-relayer', + 'google_actions_sync_url': 'test-google_actions_sync_url', } }), patch('homeassistant.components.cloud.Cloud._fetch_jwt_keyset', return_value=mock_coro(True)): @@ -43,6 +44,7 @@ def test_constructor_loads_info_from_constant(): assert cl.user_pool_id == 'test-user_pool_id' assert cl.region == 'test-region' assert cl.relayer == 'test-relayer' + assert cl.google_actions_sync_url == 'test-google_actions_sync_url' @asyncio.coroutine diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index f4ae81ad2f2..1b580d0eb9b 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -210,7 +210,7 @@ def test_cloud_connect_invalid_auth(mock_client, caplog, mock_cloud): """Test invalid auth detected by server.""" conn = iot.CloudIoT(mock_cloud) mock_client.receive.side_effect = \ - client_exceptions.WSServerHandshakeError(None, None, code=401) + client_exceptions.WSServerHandshakeError(None, None, status=401) yield from conn.connect() @@ -318,7 +318,8 @@ def test_handler_google_actions(hass): 'entity_config': { 'switch.test': { 'name': 'Config name', - 'aliases': 'Config alias' + 'aliases': 'Config alias', + 'room': 'living room' } } } @@ -347,6 +348,7 @@ def test_handler_google_actions(hass): assert device['name']['name'] == 'Config name' assert device['name']['nicknames'] == ['Config alias'] assert device['type'] == 'action.devices.types.SWITCH' + assert device['roomHint'] == 'living room' async def test_refresh_token_expired(hass): diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py new file mode 100644 index 00000000000..2c888dd2dd2 --- /dev/null +++ b/tests/components/config/test_automation.py @@ -0,0 +1,136 @@ +"""Test Automation config panel.""" +import json +from unittest.mock import patch + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components import config + + +async def test_get_device_config(hass, aiohttp_client): + """Test getting device config.""" + with patch.object(config, 'SECTIONS', ['automation']): + await async_setup_component(hass, 'config', {}) + + client = await aiohttp_client(hass.http.app) + + def mock_read(path): + """Mock reading data.""" + return [ + { + 'id': 'sun', + }, + { + 'id': 'moon', + } + ] + + with patch('homeassistant.components.config._read', mock_read): + resp = await client.get( + '/api/config/automation/config/moon') + + assert resp.status == 200 + result = await resp.json() + + assert result == {'id': 'moon'} + + +async def test_update_device_config(hass, aiohttp_client): + """Test updating device config.""" + with patch.object(config, 'SECTIONS', ['automation']): + await async_setup_component(hass, 'config', {}) + + client = await aiohttp_client(hass.http.app) + + orig_data = [ + { + 'id': 'sun', + }, + { + 'id': 'moon', + } + ] + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + with patch('homeassistant.components.config._read', mock_read), \ + patch('homeassistant.components.config._write', mock_write): + resp = await client.post( + '/api/config/automation/config/moon', data=json.dumps({ + 'trigger': [], + 'action': [], + 'condition': [], + })) + + assert resp.status == 200 + result = await resp.json() + assert result == {'result': 'ok'} + + assert list(orig_data[1]) == ['id', 'trigger', 'condition', 'action'] + assert orig_data[1] == { + 'id': 'moon', + 'trigger': [], + 'condition': [], + 'action': [], + } + assert written[0] == orig_data + + +async def test_bad_formatted_automations(hass, aiohttp_client): + """Test that we handle automations without ID.""" + with patch.object(config, 'SECTIONS', ['automation']): + await async_setup_component(hass, 'config', {}) + + client = await aiohttp_client(hass.http.app) + + orig_data = [ + { + # No ID + 'action': { + 'event': 'hello' + } + }, + { + 'id': 'moon', + } + ] + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + with patch('homeassistant.components.config._read', mock_read), \ + patch('homeassistant.components.config._write', mock_write): + resp = await client.post( + '/api/config/automation/config/moon', data=json.dumps({ + 'trigger': [], + 'action': [], + 'condition': [], + })) + + assert resp.status == 200 + result = await resp.json() + assert result == {'result': 'ok'} + + # Verify ID added to orig_data + assert 'id' in orig_data[0] + + assert orig_data[1] == { + 'id': 'moon', + 'trigger': [], + 'condition': [], + 'action': [], + } diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 84667b8704b..84d15578e13 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -8,7 +8,8 @@ import pytest import voluptuous as vol from homeassistant import config_entries as core_ce -from homeassistant.config_entries import ConfigFlowHandler, HANDLERS +from homeassistant.config_entries import HANDLERS +from homeassistant.data_entry_flow import FlowHandler from homeassistant.setup import async_setup_component from homeassistant.components.config import config_entries from homeassistant.loader import set_component @@ -16,12 +17,18 @@ from homeassistant.loader import set_component from tests.common import MockConfigEntry, MockModule, mock_coro_func +@pytest.fixture(autouse=True) +def mock_test_component(hass): + """Ensure a component called 'test' exists.""" + set_component(hass, 'test', MockModule('test')) + + @pytest.fixture -def client(hass, test_client): +def client(hass, aiohttp_client): """Fixture that can interact with the config manager API.""" hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) hass.loop.run_until_complete(config_entries.async_setup(hass)) - yield hass.loop.run_until_complete(test_client(hass.http.app)) + yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine @@ -93,7 +100,7 @@ def test_available_flows(hass, client): @asyncio.coroutine def test_initialize_flow(hass, client): """Test we can initialize a flow.""" - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): @asyncio.coroutine def async_step_init(self, user_input=None): schema = OrderedDict() @@ -110,7 +117,7 @@ def test_initialize_flow(hass, client): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() @@ -119,7 +126,7 @@ def test_initialize_flow(hass, client): assert data == { 'type': 'form', - 'domain': 'test', + 'handler': 'test', 'step_id': 'init', 'data_schema': [ { @@ -142,20 +149,20 @@ def test_initialize_flow(hass, client): @asyncio.coroutine def test_abort(hass, client): """Test a flow that aborts.""" - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): @asyncio.coroutine def async_step_init(self, user_input=None): return self.async_abort(reason='bla') with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() data.pop('flow_id') assert data == { - 'domain': 'test', + 'handler': 'test', 'reason': 'bla', 'type': 'abort' } @@ -165,9 +172,10 @@ def test_abort(hass, client): def test_create_account(hass, client): """Test a flow that creates an account.""" set_component( - 'test', MockModule('test', async_setup_entry=mock_coro_func(True))) + hass, 'test', + MockModule('test', async_setup_entry=mock_coro_func(True))) - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): VERSION = 1 @asyncio.coroutine @@ -179,15 +187,17 @@ def test_create_account(hass, client): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() data.pop('flow_id') assert data == { - 'domain': 'test', + 'handler': 'test', 'title': 'Test Entry', - 'type': 'create_entry' + 'type': 'create_entry', + 'source': 'user', + 'version': 1, } @@ -195,9 +205,10 @@ def test_create_account(hass, client): def test_two_step_flow(hass, client): """Test we can finish a two step flow.""" set_component( - 'test', MockModule('test', async_setup_entry=mock_coro_func(True))) + hass, 'test', + MockModule('test', async_setup_entry=mock_coro_func(True))) - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): VERSION = 1 @asyncio.coroutine @@ -217,13 +228,13 @@ def test_two_step_flow(hass, client): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() flow_id = data.pop('flow_id') assert data == { 'type': 'form', - 'domain': 'test', + 'handler': 'test', 'step_id': 'account', 'data_schema': [ { @@ -242,16 +253,18 @@ def test_two_step_flow(hass, client): data = yield from resp.json() data.pop('flow_id') assert data == { - 'domain': 'test', + 'handler': 'test', 'type': 'create_entry', 'title': 'user-title', + 'version': 1, + 'source': 'user', } @asyncio.coroutine def test_get_progress_index(hass, client): """Test querying for the flows that are in progress.""" - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): VERSION = 5 @asyncio.coroutine @@ -274,7 +287,7 @@ def test_get_progress_index(hass, client): assert data == [ { 'flow_id': form['flow_id'], - 'domain': 'test', + 'handler': 'test', 'source': 'hassio' } ] @@ -283,7 +296,7 @@ def test_get_progress_index(hass, client): @asyncio.coroutine def test_get_progress_flow(hass, client): """Test we can query the API for same result as we get from init a flow.""" - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): @asyncio.coroutine def async_step_init(self, user_input=None): schema = OrderedDict() @@ -300,7 +313,7 @@ def test_get_progress_flow(hass, client): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 4d82d695f8b..5b52b3d5711 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -8,14 +8,14 @@ from tests.common import mock_coro @asyncio.coroutine -def test_validate_config_ok(hass, test_client): +def test_validate_config_ok(hass, aiohttp_client): """Test checking config.""" with patch.object(config, 'SECTIONS', ['core']): yield from async_setup_component(hass, 'config', {}) yield from asyncio.sleep(0.1, loop=hass.loop) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) with patch( 'homeassistant.components.config.core.async_check_ha_config_file', diff --git a/tests/components/config/test_customize.py b/tests/components/config/test_customize.py index f12774c25d9..100a18618e6 100644 --- a/tests/components/config/test_customize.py +++ b/tests/components/config/test_customize.py @@ -9,12 +9,12 @@ from homeassistant.config import DATA_CUSTOMIZE @asyncio.coroutine -def test_get_entity(hass, test_client): +def test_get_entity(hass, aiohttp_client): """Test getting entity.""" with patch.object(config, 'SECTIONS', ['customize']): yield from async_setup_component(hass, 'config', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) def mock_read(path): """Mock reading data.""" @@ -38,12 +38,12 @@ def test_get_entity(hass, test_client): @asyncio.coroutine -def test_update_entity(hass, test_client): +def test_update_entity(hass, aiohttp_client): """Test updating entity.""" with patch.object(config, 'SECTIONS', ['customize']): yield from async_setup_component(hass, 'config', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) orig_data = { 'hello.beer': { @@ -89,12 +89,12 @@ def test_update_entity(hass, test_client): @asyncio.coroutine -def test_update_entity_invalid_key(hass, test_client): +def test_update_entity_invalid_key(hass, aiohttp_client): """Test updating entity.""" with patch.object(config, 'SECTIONS', ['customize']): yield from async_setup_component(hass, 'config', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.post( '/api/config/customize/config/not_entity', data=json.dumps({ @@ -105,12 +105,12 @@ def test_update_entity_invalid_key(hass, test_client): @asyncio.coroutine -def test_update_entity_invalid_json(hass, test_client): +def test_update_entity_invalid_json(hass, aiohttp_client): """Test updating entity.""" with patch.object(config, 'SECTIONS', ['customize']): yield from async_setup_component(hass, 'config', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.post( '/api/config/customize/config/hello.beer', data='not json') diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index aa7a5ce5f0e..fd7c6999477 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -8,11 +8,11 @@ from tests.common import mock_registry, MockEntity, MockEntityPlatform @pytest.fixture -def client(hass, test_client): +def client(hass, aiohttp_client): """Fixture that can interact with the config manager API.""" hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) hass.loop.run_until_complete(entity_registry.async_setup(hass)) - yield hass.loop.run_until_complete(test_client(hass.http.app)) + yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) async def test_get_entity(hass, client): diff --git a/tests/components/config/test_group.py b/tests/components/config/test_group.py index ad28b6eb9b8..06ba2ff1105 100644 --- a/tests/components/config/test_group.py +++ b/tests/components/config/test_group.py @@ -11,12 +11,12 @@ VIEW_NAME = 'api:config:group:config' @asyncio.coroutine -def test_get_device_config(hass, test_client): +def test_get_device_config(hass, aiohttp_client): """Test getting device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) def mock_read(path): """Mock reading data.""" @@ -40,12 +40,12 @@ def test_get_device_config(hass, test_client): @asyncio.coroutine -def test_update_device_config(hass, test_client): +def test_update_device_config(hass, aiohttp_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) orig_data = { 'hello.beer': { @@ -89,12 +89,12 @@ def test_update_device_config(hass, test_client): @asyncio.coroutine -def test_update_device_config_invalid_key(hass, test_client): +def test_update_device_config_invalid_key(hass, aiohttp_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.post( '/api/config/group/config/not a slug', data=json.dumps({ @@ -105,12 +105,12 @@ def test_update_device_config_invalid_key(hass, test_client): @asyncio.coroutine -def test_update_device_config_invalid_data(hass, test_client): +def test_update_device_config_invalid_data(hass, aiohttp_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.post( '/api/config/group/config/hello_beer', data=json.dumps({ @@ -121,12 +121,12 @@ def test_update_device_config_invalid_data(hass, test_client): @asyncio.coroutine -def test_update_device_config_invalid_json(hass, test_client): +def test_update_device_config_invalid_json(hass, aiohttp_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.post( '/api/config/group/config/hello_beer', data='not json') diff --git a/tests/components/config/test_hassbian.py b/tests/components/config/test_hassbian.py index 9038ccc6aa4..85fbf0c2e5a 100644 --- a/tests/components/config/test_hassbian.py +++ b/tests/components/config/test_hassbian.py @@ -34,13 +34,13 @@ def test_setup_check_env_works(hass, loop): @asyncio.coroutine -def test_get_suites(hass, test_client): +def test_get_suites(hass, aiohttp_client): """Test getting suites.""" with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ patch.object(config, 'SECTIONS', ['hassbian']): yield from async_setup_component(hass, 'config', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.get('/api/config/hassbian/suites') assert resp.status == 200 result = yield from resp.json() @@ -53,13 +53,13 @@ def test_get_suites(hass, test_client): @asyncio.coroutine -def test_install_suite(hass, test_client): +def test_install_suite(hass, aiohttp_client): """Test getting suites.""" with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ patch.object(config, 'SECTIONS', ['hassbian']): yield from async_setup_component(hass, 'config', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.post( '/api/config/hassbian/suites/openzwave/install') assert resp.status == 200 diff --git a/tests/components/config/test_init.py b/tests/components/config/test_init.py index 2d5d814ac8a..57ea7e7a492 100644 --- a/tests/components/config/test_init.py +++ b/tests/components/config/test_init.py @@ -17,7 +17,7 @@ def test_config_setup(hass, loop): @asyncio.coroutine -def test_load_on_demand_already_loaded(hass, test_client): +def test_load_on_demand_already_loaded(hass, aiohttp_client): """Test getting suites.""" mock_component(hass, 'zwave') @@ -34,7 +34,7 @@ def test_load_on_demand_already_loaded(hass, test_client): @asyncio.coroutine -def test_load_on_demand_on_load(hass, test_client): +def test_load_on_demand_on_load(hass, aiohttp_client): """Test getting suites.""" with patch.object(config, 'SECTIONS', []), \ patch.object(config, 'ON_DEMAND', ['zwave']): diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index c98385a3c32..672bafeaf28 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -16,12 +16,12 @@ VIEW_NAME = 'api:config:zwave:device_config' @pytest.fixture -def client(loop, hass, test_client): +def client(loop, hass, aiohttp_client): """Client to communicate with Z-Wave config views.""" with patch.object(config, 'SECTIONS', ['zwave']): loop.run_until_complete(async_setup_component(hass, 'config', {})) - return loop.run_until_complete(test_client(hass.http.app)) + return loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine diff --git a/tests/components/conftest.py b/tests/components/conftest.py new file mode 100644 index 00000000000..8a1b934ab76 --- /dev/null +++ b/tests/components/conftest.py @@ -0,0 +1,38 @@ +"""Fixtures for component testing.""" +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import MockUser + + +@pytest.fixture +def hass_ws_client(aiohttp_client): + """Websocket client fixture connected to websocket server.""" + async def create_client(hass): + """Create a websocket client.""" + wapi = hass.components.websocket_api + assert await async_setup_component(hass, 'websocket_api') + + client = await aiohttp_client(hass.http.app) + websocket = await client.ws_connect(wapi.URL) + auth_ok = await websocket.receive_json() + assert auth_ok['type'] == wapi.TYPE_AUTH_OK + + return websocket + + return create_client + + +@pytest.fixture +def hass_access_token(hass): + """Return an access token to access Home Assistant.""" + user = MockUser().add_to_hass(hass) + client = hass.loop.run_until_complete(hass.auth.async_create_client( + 'Access Token Fixture', + redirect_uris=['/'], + no_secret=True, + )) + refresh_token = hass.loop.run_until_complete( + hass.auth.async_create_refresh_token(user, client.id)) + yield hass.auth.async_create_access_token(refresh_token) diff --git a/tests/components/cover/test_group.py b/tests/components/cover/test_group.py new file mode 100644 index 00000000000..288e1c5e047 --- /dev/null +++ b/tests/components/cover/test_group.py @@ -0,0 +1,350 @@ +"""The tests for the group cover platform.""" + +import unittest +from datetime import timedelta +import homeassistant.util.dt as dt_util + +from homeassistant import setup +from homeassistant.components import cover +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION, DOMAIN) +from homeassistant.components.cover.group import DEFAULT_NAME +from homeassistant.const import ( + ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, + CONF_ENTITIES, STATE_OPEN, STATE_CLOSED) +from tests.common import ( + assert_setup_component, get_test_home_assistant, fire_time_changed) + +COVER_GROUP = 'cover.cover_group' +DEMO_COVER = 'cover.kitchen_window' +DEMO_COVER_POS = 'cover.hall_window' +DEMO_COVER_TILT = 'cover.living_room_window' +DEMO_TILT = 'cover.tilt_demo' + +CONFIG = { + DOMAIN: [ + {'platform': 'demo'}, + {'platform': 'group', + CONF_ENTITIES: [ + DEMO_COVER, DEMO_COVER_POS, DEMO_COVER_TILT, DEMO_TILT]} + ] +} + + +class TestMultiCover(unittest.TestCase): + """Test the group cover platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + def test_attributes(self): + """Test handling of state attributes.""" + config = {DOMAIN: {'platform': 'group', CONF_ENTITIES: [ + DEMO_COVER, DEMO_COVER_POS, DEMO_COVER_TILT, DEMO_TILT]}} + + with assert_setup_component(1, DOMAIN): + assert setup.setup_component(self.hass, DOMAIN, config) + + state = self.hass.states.get(COVER_GROUP) + attr = state.attributes + self.assertEqual(state.state, STATE_CLOSED) + self.assertEqual(attr.get(ATTR_FRIENDLY_NAME), DEFAULT_NAME) + self.assertEqual(attr.get(ATTR_ASSUMED_STATE), None) + self.assertEqual(attr.get(ATTR_SUPPORTED_FEATURES), 0) + self.assertEqual(attr.get(ATTR_CURRENT_POSITION), None) + self.assertEqual(attr.get(ATTR_CURRENT_TILT_POSITION), None) + + # Add Entity that supports open / close / stop + self.hass.states.set( + DEMO_COVER, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 11}) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + attr = state.attributes + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(attr.get(ATTR_ASSUMED_STATE), None) + self.assertEqual(attr.get(ATTR_SUPPORTED_FEATURES), 11) + self.assertEqual(attr.get(ATTR_CURRENT_POSITION), None) + self.assertEqual(attr.get(ATTR_CURRENT_TILT_POSITION), None) + + # Add Entity that supports set_cover_position + self.hass.states.set( + DEMO_COVER_POS, STATE_OPEN, + {ATTR_SUPPORTED_FEATURES: 4, ATTR_CURRENT_POSITION: 70}) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + attr = state.attributes + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(attr.get(ATTR_ASSUMED_STATE), None) + self.assertEqual(attr.get(ATTR_SUPPORTED_FEATURES), 15) + self.assertEqual(attr.get(ATTR_CURRENT_POSITION), 70) + self.assertEqual(attr.get(ATTR_CURRENT_TILT_POSITION), None) + + # Add Entity that supports open tilt / close tilt / stop tilt + self.hass.states.set( + DEMO_TILT, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 112}) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + attr = state.attributes + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(attr.get(ATTR_ASSUMED_STATE), None) + self.assertEqual(attr.get(ATTR_SUPPORTED_FEATURES), 127) + self.assertEqual(attr.get(ATTR_CURRENT_POSITION), 70) + self.assertEqual(attr.get(ATTR_CURRENT_TILT_POSITION), None) + + # Add Entity that supports set_tilt_position + self.hass.states.set( + DEMO_COVER_TILT, STATE_OPEN, + {ATTR_SUPPORTED_FEATURES: 128, ATTR_CURRENT_TILT_POSITION: 60}) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + attr = state.attributes + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(attr.get(ATTR_ASSUMED_STATE), None) + self.assertEqual(attr.get(ATTR_SUPPORTED_FEATURES), 255) + self.assertEqual(attr.get(ATTR_CURRENT_POSITION), 70) + self.assertEqual(attr.get(ATTR_CURRENT_TILT_POSITION), 60) + + # ### Test assumed state ### + # ########################## + + # For covers + self.hass.states.set( + DEMO_COVER, STATE_OPEN, + {ATTR_SUPPORTED_FEATURES: 4, ATTR_CURRENT_POSITION: 100}) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + attr = state.attributes + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(attr.get(ATTR_ASSUMED_STATE), True) + self.assertEqual(attr.get(ATTR_SUPPORTED_FEATURES), 244) + self.assertEqual(attr.get(ATTR_CURRENT_POSITION), 100) + self.assertEqual(attr.get(ATTR_CURRENT_TILT_POSITION), 60) + + self.hass.states.remove(DEMO_COVER) + self.hass.block_till_done() + self.hass.states.remove(DEMO_COVER_POS) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + attr = state.attributes + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(attr.get(ATTR_ASSUMED_STATE), None) + self.assertEqual(attr.get(ATTR_SUPPORTED_FEATURES), 240) + self.assertEqual(attr.get(ATTR_CURRENT_POSITION), None) + self.assertEqual(attr.get(ATTR_CURRENT_TILT_POSITION), 60) + + # For tilts + self.hass.states.set( + DEMO_TILT, STATE_OPEN, + {ATTR_SUPPORTED_FEATURES: 128, ATTR_CURRENT_TILT_POSITION: 100}) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + attr = state.attributes + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(attr.get(ATTR_ASSUMED_STATE), True) + self.assertEqual(attr.get(ATTR_SUPPORTED_FEATURES), 128) + self.assertEqual(attr.get(ATTR_CURRENT_POSITION), None) + self.assertEqual(attr.get(ATTR_CURRENT_TILT_POSITION), 100) + + self.hass.states.remove(DEMO_COVER_TILT) + self.hass.states.set(DEMO_TILT, STATE_CLOSED) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + attr = state.attributes + self.assertEqual(state.state, STATE_CLOSED) + self.assertEqual(attr.get(ATTR_ASSUMED_STATE), None) + self.assertEqual(attr.get(ATTR_SUPPORTED_FEATURES), 0) + self.assertEqual(attr.get(ATTR_CURRENT_POSITION), None) + self.assertEqual(attr.get(ATTR_CURRENT_TILT_POSITION), None) + + self.hass.states.set( + DEMO_TILT, STATE_CLOSED, {ATTR_ASSUMED_STATE: True}) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + attr = state.attributes + self.assertEqual(attr.get(ATTR_ASSUMED_STATE), True) + + def test_open_covers(self): + """Test open cover function.""" + with assert_setup_component(2, DOMAIN): + assert setup.setup_component(self.hass, DOMAIN, CONFIG) + + cover.open_cover(self.hass, COVER_GROUP) + self.hass.block_till_done() + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(state.attributes.get(ATTR_CURRENT_POSITION), 100) + + self.assertEqual(self.hass.states.get(DEMO_COVER).state, STATE_OPEN) + self.assertEqual(self.hass.states.get(DEMO_COVER_POS) + .attributes.get(ATTR_CURRENT_POSITION), 100) + self.assertEqual(self.hass.states.get(DEMO_COVER_TILT) + .attributes.get(ATTR_CURRENT_POSITION), 100) + + def test_close_covers(self): + """Test close cover function.""" + with assert_setup_component(2, DOMAIN): + assert setup.setup_component(self.hass, DOMAIN, CONFIG) + + cover.close_cover(self.hass, COVER_GROUP) + self.hass.block_till_done() + for _ in range(10): + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + self.assertEqual(state.state, STATE_CLOSED) + self.assertEqual(state.attributes.get(ATTR_CURRENT_POSITION), 0) + + self.assertEqual(self.hass.states.get(DEMO_COVER).state, STATE_CLOSED) + self.assertEqual(self.hass.states.get(DEMO_COVER_POS) + .attributes.get(ATTR_CURRENT_POSITION), 0) + self.assertEqual(self.hass.states.get(DEMO_COVER_TILT) + .attributes.get(ATTR_CURRENT_POSITION), 0) + + def test_stop_covers(self): + """Test stop cover function.""" + with assert_setup_component(2, DOMAIN): + assert setup.setup_component(self.hass, DOMAIN, CONFIG) + + cover.open_cover(self.hass, COVER_GROUP) + self.hass.block_till_done() + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.block_till_done() + cover.stop_cover(self.hass, COVER_GROUP) + self.hass.block_till_done() + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(state.attributes.get(ATTR_CURRENT_POSITION), 100) + + self.assertEqual(self.hass.states.get(DEMO_COVER).state, STATE_OPEN) + self.assertEqual(self.hass.states.get(DEMO_COVER_POS) + .attributes.get(ATTR_CURRENT_POSITION), 20) + self.assertEqual(self.hass.states.get(DEMO_COVER_TILT) + .attributes.get(ATTR_CURRENT_POSITION), 80) + + def test_set_cover_position(self): + """Test set cover position function.""" + with assert_setup_component(2, DOMAIN): + assert setup.setup_component(self.hass, DOMAIN, CONFIG) + + cover.set_cover_position(self.hass, 50, COVER_GROUP) + self.hass.block_till_done() + for _ in range(4): + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(state.attributes.get(ATTR_CURRENT_POSITION), 50) + + self.assertEqual(self.hass.states.get(DEMO_COVER).state, STATE_CLOSED) + self.assertEqual(self.hass.states.get(DEMO_COVER_POS) + .attributes.get(ATTR_CURRENT_POSITION), 50) + self.assertEqual(self.hass.states.get(DEMO_COVER_TILT) + .attributes.get(ATTR_CURRENT_POSITION), 50) + + def test_open_tilts(self): + """Test open tilt function.""" + with assert_setup_component(2, DOMAIN): + assert setup.setup_component(self.hass, DOMAIN, CONFIG) + + cover.open_cover_tilt(self.hass, COVER_GROUP) + self.hass.block_till_done() + for _ in range(5): + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(state.attributes.get(ATTR_CURRENT_TILT_POSITION), 100) + + self.assertEqual(self.hass.states.get(DEMO_COVER_TILT) + .attributes.get(ATTR_CURRENT_TILT_POSITION), 100) + + def test_close_tilts(self): + """Test close tilt function.""" + with assert_setup_component(2, DOMAIN): + assert setup.setup_component(self.hass, DOMAIN, CONFIG) + + cover.close_cover_tilt(self.hass, COVER_GROUP) + self.hass.block_till_done() + for _ in range(5): + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(state.attributes.get(ATTR_CURRENT_TILT_POSITION), 0) + + self.assertEqual(self.hass.states.get(DEMO_COVER_TILT) + .attributes.get(ATTR_CURRENT_TILT_POSITION), 0) + + def test_stop_tilts(self): + """Test stop tilts function.""" + with assert_setup_component(2, DOMAIN): + assert setup.setup_component(self.hass, DOMAIN, CONFIG) + + cover.open_cover_tilt(self.hass, COVER_GROUP) + self.hass.block_till_done() + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.block_till_done() + cover.stop_cover_tilt(self.hass, COVER_GROUP) + self.hass.block_till_done() + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(state.attributes.get(ATTR_CURRENT_TILT_POSITION), 60) + + self.assertEqual(self.hass.states.get(DEMO_COVER_TILT) + .attributes.get(ATTR_CURRENT_TILT_POSITION), 60) + + def test_set_tilt_positions(self): + """Test set tilt position function.""" + with assert_setup_component(2, DOMAIN): + assert setup.setup_component(self.hass, DOMAIN, CONFIG) + + cover.set_cover_tilt_position(self.hass, 80, COVER_GROUP) + self.hass.block_till_done() + for _ in range(3): + future = dt_util.utcnow() + timedelta(seconds=1) + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + state = self.hass.states.get(COVER_GROUP) + self.assertEqual(state.state, STATE_OPEN) + self.assertEqual(state.attributes.get(ATTR_CURRENT_TILT_POSITION), 80) + + self.assertEqual(self.hass.states.get(DEMO_COVER_TILT) + .attributes.get(ATTR_CURRENT_TILT_POSITION), 80) diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py new file mode 100755 index 00000000000..5df492d3d47 --- /dev/null +++ b/tests/components/cover/test_init.py @@ -0,0 +1,49 @@ +"""The tests for the cover platform.""" + +from homeassistant.components.cover import (SERVICE_OPEN_COVER, + SERVICE_CLOSE_COVER) +from homeassistant.components import intent +import homeassistant.components as comps +from tests.common import async_mock_service + + +async def test_open_cover_intent(hass): + """Test HassOpenCover intent.""" + result = await comps.cover.async_setup(hass, {}) + assert result + + hass.states.async_set('cover.garage_door', 'closed') + calls = async_mock_service(hass, 'cover', SERVICE_OPEN_COVER) + + response = await intent.async_handle( + hass, 'test', 'HassOpenCover', {'name': {'value': 'garage door'}} + ) + await hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Opened garage door' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'cover' + assert call.service == 'open_cover' + assert call.data == {'entity_id': 'cover.garage_door'} + + +async def test_close_cover_intent(hass): + """Test HassCloseCover intent.""" + result = await comps.cover.async_setup(hass, {}) + assert result + + hass.states.async_set('cover.garage_door', 'open') + calls = async_mock_service(hass, 'cover', SERVICE_CLOSE_COVER) + + response = await intent.async_handle( + hass, 'test', 'HassCloseCover', {'name': {'value': 'garage door'}} + ) + await hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Closed garage door' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'cover' + assert call.service == 'close_cover' + assert call.data == {'entity_id': 'cover.garage_door'} diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 23a7b32fc28..aea6398e3ae 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -2,8 +2,8 @@ import unittest from homeassistant.setup import setup_component -from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN,\ - STATE_UNAVAILABLE +from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN, \ + STATE_UNAVAILABLE, ATTR_ASSUMED_STATE import homeassistant.components.cover as cover from homeassistant.components.cover.mqtt import MqttCover @@ -40,6 +40,7 @@ class TestCoverMQTT(unittest.TestCase): state = self.hass.states.get('cover.test') self.assertEqual(STATE_UNKNOWN, state.state) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) fire_mqtt_message(self.hass, 'state-topic', '0') self.hass.block_till_done() @@ -112,6 +113,7 @@ class TestCoverMQTT(unittest.TestCase): state = self.hass.states.get('cover.test') self.assertEqual(STATE_UNKNOWN, state.state) + self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) cover.open_cover(self.hass, 'cover.test') self.hass.block_till_done() diff --git a/tests/components/deconz/__init__.py b/tests/components/deconz/__init__.py new file mode 100644 index 00000000000..59b903e8900 --- /dev/null +++ b/tests/components/deconz/__init__.py @@ -0,0 +1 @@ +"""Tests for the deCONZ component.""" diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py new file mode 100644 index 00000000000..df3310f3d6f --- /dev/null +++ b/tests/components/deconz/test_config_flow.py @@ -0,0 +1,250 @@ +"""Tests for deCONZ config flow.""" +from unittest.mock import patch +import pytest + +import voluptuous as vol +from homeassistant.components.deconz import config_flow +from tests.common import MockConfigEntry + +import pydeconz + + +async def test_flow_works(hass, aioclient_mock): + """Test that config flow works.""" + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ + {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': 80} + ]) + aioclient_mock.post('http://1.2.3.4:80/api', json=[ + {"success": {"username": "1234567890ABCDEF"}} + ]) + + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + await flow.async_step_init() + await flow.async_step_link(user_input={}) + result = await flow.async_step_options( + user_input={'allow_clip_sensor': True}) + + assert result['type'] == 'create_entry' + assert result['title'] == 'deCONZ-id' + assert result['data'] == { + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF', + 'allow_clip_sensor': True + } + + +async def test_flow_already_registered_bridge(hass): + """Test config flow don't allow more than one bridge to be registered.""" + MockConfigEntry(domain='deconz', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +async def test_flow_no_discovered_bridges(hass, aioclient_mock): + """Test config flow discovers no bridges.""" + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[]) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +async def test_flow_one_bridge_discovered(hass, aioclient_mock): + """Test config flow discovers one bridge.""" + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ + {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': 80} + ]) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_flow_two_bridges_discovered(hass, aioclient_mock): + """Test config flow discovers two bridges.""" + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ + {'id': 'id1', 'internalipaddress': '1.2.3.4', 'internalport': 80}, + {'id': 'id2', 'internalipaddress': '5.6.7.8', 'internalport': 80} + ]) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'init' + + with pytest.raises(vol.Invalid): + assert result['data_schema']({'host': '0.0.0.0'}) + + result['data_schema']({'host': '1.2.3.4'}) + result['data_schema']({'host': '5.6.7.8'}) + + +async def test_link_no_api_key(hass, aioclient_mock): + """Test config flow should abort if no API key was possible to retrieve.""" + aioclient_mock.post('http://1.2.3.4:80/api', json=[]) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + flow.deconz_config = {'host': '1.2.3.4', 'port': 80} + + result = await flow.async_step_link(user_input={}) + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == {'base': 'no_key'} + + +async def test_link_already_registered_bridge(hass): + """Test that link verifies to only allow one config entry to complete. + + This is possible with discovery which will allow the user to complete + a second config entry and then complete the discovered config entry. + """ + MockConfigEntry(domain='deconz', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + flow.deconz_config = {'host': '1.2.3.4', 'port': 80} + + result = await flow.async_step_link(user_input={}) + assert result['type'] == 'abort' + + +async def test_bridge_discovery(hass): + """Test a bridge being discovered with no additional config file.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + with patch.object(config_flow, 'load_json', return_value={}): + result = await flow.async_step_discovery({ + 'host': '1.2.3.4', + 'port': 80, + 'serial': 'id' + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_bridge_discovery_config_file(hass): + """Test a bridge being discovered with a corresponding config file.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + with patch.object(config_flow, 'load_json', + return_value={'host': '1.2.3.4', + 'port': 8080, + 'api_key': '1234567890ABCDEF'}): + result = await flow.async_step_discovery({ + 'host': '1.2.3.4', + 'port': 80, + 'serial': 'id' + }) + assert result['type'] == 'create_entry' + assert result['title'] == 'deCONZ-id' + assert result['data'] == { + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF', + 'allow_clip_sensor': True + } + + +async def test_bridge_discovery_other_config_file(hass): + """Test a bridge being discovered with another bridges config file.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + with patch.object(config_flow, 'load_json', + return_value={'host': '5.6.7.8', 'api_key': '5678'}): + result = await flow.async_step_discovery({ + 'host': '1.2.3.4', + 'port': 80, + 'serial': 'id' + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_bridge_discovery_already_configured(hass): + """Test if a discovered bridge has already been configured.""" + MockConfigEntry(domain='deconz', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery({ + 'host': '1.2.3.4', + 'serial': 'id' + }) + + assert result['type'] == 'abort' + + +async def test_import_without_api_key(hass): + """Test importing a host without an API key.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + 'host': '1.2.3.4', + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_import_with_api_key(hass): + """Test importing a host with an API key.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF' + }) + assert result['type'] == 'create_entry' + assert result['title'] == 'deCONZ-id' + assert result['data'] == { + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF', + 'allow_clip_sensor': True + } + + +async def test_options(hass, aioclient_mock): + """Test that options work and that bridgeid can be requested.""" + aioclient_mock.get('http://1.2.3.4:80/api/1234567890ABCDEF/config', + json={"bridgeid": "id"}) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + flow.deconz_config = {'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF'} + result = await flow.async_step_options( + user_input={'allow_clip_sensor': False}) + assert result['type'] == 'create_entry' + assert result['title'] == 'deCONZ-id' + assert result['data'] == { + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF', + 'allow_clip_sensor': False + } diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py new file mode 100644 index 00000000000..1cee08feb0a --- /dev/null +++ b/tests/components/deconz/test_init.py @@ -0,0 +1,192 @@ +"""Test deCONZ component setup process.""" +from unittest.mock import Mock, patch + +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.setup import async_setup_component +from homeassistant.components import deconz + +from tests.common import mock_coro + + +async def test_config_with_host_passed_to_config_entry(hass): + """Test that configured options for a host are loaded via config entry.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(deconz, 'configured_hosts', return_value=[]), \ + patch.object(deconz, 'load_json', return_value={}): + assert await async_setup_component(hass, deconz.DOMAIN, { + deconz.DOMAIN: { + deconz.CONF_HOST: '1.2.3.4', + deconz.CONF_PORT: 80 + } + }) is True + # Import flow started + assert len(mock_config_entries.flow.mock_calls) == 2 + + +async def test_config_file_passed_to_config_entry(hass): + """Test that configuration file for a host are loaded via config entry.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(deconz, 'configured_hosts', return_value=[]), \ + patch.object(deconz, 'load_json', + return_value={'host': '1.2.3.4'}): + assert await async_setup_component(hass, deconz.DOMAIN, { + deconz.DOMAIN: {} + }) is True + # Import flow started + assert len(mock_config_entries.flow.mock_calls) == 2 + + +async def test_config_without_host_not_passed_to_config_entry(hass): + """Test that a configuration without a host does not initiate an import.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(deconz, 'configured_hosts', return_value=[]), \ + patch.object(deconz, 'load_json', return_value={}): + assert await async_setup_component(hass, deconz.DOMAIN, { + deconz.DOMAIN: {} + }) is True + # No flow started + assert len(mock_config_entries.flow.mock_calls) == 0 + + +async def test_config_already_registered_not_passed_to_config_entry(hass): + """Test that an already registered host does not initiate an import.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(deconz, 'configured_hosts', + return_value=['1.2.3.4']), \ + patch.object(deconz, 'load_json', return_value={}): + assert await async_setup_component(hass, deconz.DOMAIN, { + deconz.DOMAIN: { + deconz.CONF_HOST: '1.2.3.4', + deconz.CONF_PORT: 80 + } + }) is True + # No flow started + assert len(mock_config_entries.flow.mock_calls) == 0 + + +async def test_config_discovery(hass): + """Test that a discovered bridge does not initiate an import.""" + with patch.object(hass, 'config_entries') as mock_config_entries: + assert await async_setup_component(hass, deconz.DOMAIN, {}) is True + # No flow started + assert len(mock_config_entries.flow.mock_calls) == 0 + + +async def test_setup_entry_already_registered_bridge(hass): + """Test setup entry doesn't allow more than one instance of deCONZ.""" + hass.data[deconz.DOMAIN] = True + assert await deconz.async_setup_entry(hass, {}) is False + + +async def test_setup_entry_no_available_bridge(hass): + """Test setup entry fails if deCONZ is not available.""" + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + with patch('pydeconz.DeconzSession.async_load_parameters', + return_value=mock_coro(False)): + assert await deconz.async_setup_entry(hass, entry) is False + + +async def test_setup_entry_successful(hass): + """Test setup entry is successful.""" + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + with patch.object(hass, 'async_add_job') as mock_add_job, \ + patch.object(hass, 'config_entries') as mock_config_entries, \ + patch('pydeconz.DeconzSession.async_load_parameters', + return_value=mock_coro(True)): + assert await deconz.async_setup_entry(hass, entry) is True + assert hass.data[deconz.DOMAIN] + assert hass.data[deconz.DATA_DECONZ_ID] == {} + assert len(hass.data[deconz.DATA_DECONZ_UNSUB]) == 1 + assert len(mock_add_job.mock_calls) == 4 + assert len(mock_config_entries.async_forward_entry_setup.mock_calls) == 4 + assert mock_config_entries.async_forward_entry_setup.mock_calls[0][1] == \ + (entry, 'binary_sensor') + assert mock_config_entries.async_forward_entry_setup.mock_calls[1][1] == \ + (entry, 'light') + assert mock_config_entries.async_forward_entry_setup.mock_calls[2][1] == \ + (entry, 'scene') + assert mock_config_entries.async_forward_entry_setup.mock_calls[3][1] == \ + (entry, 'sensor') + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + with patch('pydeconz.DeconzSession.async_load_parameters', + return_value=mock_coro(True)): + assert await deconz.async_setup_entry(hass, entry) is True + assert deconz.DATA_DECONZ_EVENT in hass.data + hass.data[deconz.DATA_DECONZ_EVENT].append(Mock()) + hass.data[deconz.DATA_DECONZ_ID] = {'id': 'deconzid'} + assert await deconz.async_unload_entry(hass, entry) + assert deconz.DOMAIN not in hass.data + assert len(hass.data[deconz.DATA_DECONZ_UNSUB]) == 0 + assert len(hass.data[deconz.DATA_DECONZ_EVENT]) == 0 + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + + +async def test_add_new_device(hass): + """Test adding a new device generates a signal for platforms.""" + new_event = { + "t": "event", + "e": "added", + "r": "sensors", + "id": "1", + "sensor": { + "config": { + "on": "True", + "reachable": "True" + }, + "name": "event", + "state": {}, + "type": "ZHASwitch" + } + } + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + with patch.object(deconz, 'async_dispatcher_send') as mock_dispatch_send, \ + patch('pydeconz.DeconzSession.async_load_parameters', + return_value=mock_coro(True)): + assert await deconz.async_setup_entry(hass, entry) is True + hass.data[deconz.DOMAIN].async_event_handler(new_event) + await hass.async_block_till_done() + assert len(mock_dispatch_send.mock_calls) == 1 + assert len(mock_dispatch_send.mock_calls[0]) == 3 + + +async def test_add_new_remote(hass): + """Test new added device creates a new remote.""" + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + remote = Mock() + remote.name = 'name' + remote.type = 'ZHASwitch' + remote.register_async_callback = Mock() + with patch('pydeconz.DeconzSession.async_load_parameters', + return_value=mock_coro(True)): + assert await deconz.async_setup_entry(hass, entry) is True + + async_dispatcher_send(hass, 'deconz_new_sensor', [remote]) + await hass.async_block_till_done() + assert len(hass.data[deconz.DATA_DECONZ_EVENT]) == 1 + + +async def test_do_not_allow_clip_sensor(hass): + """Test that clip sensors can be ignored.""" + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, + 'api_key': '1234567890ABCDEF', 'allow_clip_sensor': False} + remote = Mock() + remote.name = 'name' + remote.type = 'CLIPSwitch' + remote.register_async_callback = Mock() + with patch('pydeconz.DeconzSession.async_load_parameters', + return_value=mock_coro(True)): + assert await deconz.async_setup_entry(hass, entry) is True + + async_dispatcher_send(hass, 'deconz_new_sensor', [remote]) + await hass.async_block_till_done() + assert len(hass.data[deconz.DATA_DECONZ_EVENT]) == 0 diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 27f28412561..0cbece6d1b0 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -3,9 +3,9 @@ import os from datetime import timedelta import unittest from unittest import mock +import socket import voluptuous as vol -from future.backports import socket from homeassistant.setup import setup_component from homeassistant.components import device_tracker @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.components.device_tracker.asuswrt import ( CONF_PROTOCOL, CONF_MODE, CONF_PUB_KEY, DOMAIN, _ARP_REGEX, CONF_PORT, PLATFORM_SCHEMA, Device, get_scanner, AsusWrtDeviceScanner, - _parse_lines, SshConnection, TelnetConnection) + _parse_lines, SshConnection, TelnetConnection, CONF_REQUIRE_IP) from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, CONF_HOST) @@ -105,6 +105,15 @@ WAKE_DEVICES_AP = { mac='08:09:10:11:12:14', ip='123.123.123.126', name=None) } +WAKE_DEVICES_NO_IP = { + '01:02:03:04:06:08': Device( + mac='01:02:03:04:06:08', ip='123.123.123.125', name=None), + '08:09:10:11:12:14': Device( + mac='08:09:10:11:12:14', ip='123.123.123.126', name=None), + '08:09:10:11:12:15': Device( + mac='08:09:10:11:12:15', ip=None, name=None) +} + def setup_module(): """Setup the test module.""" @@ -411,6 +420,21 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): scanner._get_leases.return_value = LEASES_DEVICES self.assertEqual(WAKE_DEVICES_AP, scanner.get_asuswrt_data()) + def test_get_asuswrt_data_no_ip(self): + """Test for get asuswrt_data and not requiring ip.""" + conf = VALID_CONFIG_ROUTER_SSH.copy()[DOMAIN] + conf[CONF_REQUIRE_IP] = False + scanner = AsusWrtDeviceScanner(conf) + scanner._get_wl = mock.Mock() + scanner._get_arp = mock.Mock() + scanner._get_neigh = mock.Mock() + scanner._get_leases = mock.Mock() + scanner._get_wl.return_value = WL_DEVICES + scanner._get_arp.return_value = ARP_DEVICES + scanner._get_neigh.return_value = NEIGH_DEVICES + scanner._get_leases.return_value = LEASES_DEVICES + self.assertEqual(WAKE_DEVICES_NO_IP, scanner.get_asuswrt_data()) + def test_update_info(self): """Test for update info.""" scanner = get_scanner(self.hass, VALID_CONFIG_ROUTER_SSH) diff --git a/tests/components/device_tracker/test_geofency.py b/tests/components/device_tracker/test_geofency.py index 5def6a217f4..a955dd0cc11 100644 --- a/tests/components/device_tracker/test_geofency.py +++ b/tests/components/device_tracker/test_geofency.py @@ -107,7 +107,7 @@ BEACON_EXIT_CAR = { @pytest.fixture -def geofency_client(loop, hass, test_client): +def geofency_client(loop, hass, aiohttp_client): """Geofency mock client.""" assert loop.run_until_complete(async_setup_component( hass, device_tracker.DOMAIN, { @@ -117,7 +117,7 @@ def geofency_client(loop, hass, test_client): }})) with patch('homeassistant.components.device_tracker.update_config'): - yield loop.run_until_complete(test_client(hass.http.app)) + yield loop.run_until_complete(aiohttp_client(hass.http.app)) @pytest.fixture(autouse=True) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index c051983d8fa..0b17b4e0ac8 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -24,9 +24,7 @@ from homeassistant.remote import JSONEncoder from tests.common import ( get_test_home_assistant, fire_time_changed, - patch_yaml_files, assert_setup_component, mock_restore_cache, mock_coro) - -from ...test_util.aiohttp import mock_aiohttp_client + patch_yaml_files, assert_setup_component, mock_restore_cache) TEST_PLATFORM = {device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}} @@ -111,7 +109,6 @@ class TestComponentsDeviceTracker(unittest.TestCase): self.assertEqual(device.config_picture, config.config_picture) self.assertEqual(device.away_hide, config.away_hide) self.assertEqual(device.consider_home, config.consider_home) - self.assertEqual(device.vendor, config.vendor) self.assertEqual(device.icon, config.icon) # pylint: disable=invalid-name @@ -173,124 +170,6 @@ class TestComponentsDeviceTracker(unittest.TestCase): "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar") self.assertEqual(device.config_picture, gravatar_url) - def test_mac_vendor_lookup(self): - """Test if vendor string is lookup on macvendors API.""" - mac = 'B8:27:EB:00:00:00' - vendor_string = 'Raspberry Pi Foundation' - - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, 'test', mac, 'Test name') - - with mock_aiohttp_client() as aioclient_mock: - aioclient_mock.get('http://api.macvendors.com/b8:27:eb', - text=vendor_string) - - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - assert aioclient_mock.call_count == 1 - - self.assertEqual(device.vendor, vendor_string) - - def test_mac_vendor_mac_formats(self): - """Verify all variations of MAC addresses are handled correctly.""" - vendor_string = 'Raspberry Pi Foundation' - - with mock_aiohttp_client() as aioclient_mock: - aioclient_mock.get('http://api.macvendors.com/b8:27:eb', - text=vendor_string) - aioclient_mock.get('http://api.macvendors.com/00:27:eb', - text=vendor_string) - - mac = 'B8:27:EB:00:00:00' - device = device_tracker.Device( - self.hass, timedelta(seconds=180), - True, 'test', mac, 'Test name') - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - self.assertEqual(device.vendor, vendor_string) - - mac = '0:27:EB:00:00:00' - device = device_tracker.Device( - self.hass, timedelta(seconds=180), - True, 'test', mac, 'Test name') - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - self.assertEqual(device.vendor, vendor_string) - - mac = 'PREFIXED_B8:27:EB:00:00:00' - device = device_tracker.Device( - self.hass, timedelta(seconds=180), - True, 'test', mac, 'Test name') - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - self.assertEqual(device.vendor, vendor_string) - - def test_mac_vendor_lookup_unknown(self): - """Prevent another mac vendor lookup if was not found first time.""" - mac = 'B8:27:EB:00:00:00' - - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, 'test', mac, 'Test name') - - with mock_aiohttp_client() as aioclient_mock: - aioclient_mock.get('http://api.macvendors.com/b8:27:eb', - status=404) - - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - - self.assertEqual(device.vendor, 'unknown') - - def test_mac_vendor_lookup_error(self): - """Prevent another lookup if failure during API call.""" - mac = 'B8:27:EB:00:00:00' - - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, 'test', mac, 'Test name') - - with mock_aiohttp_client() as aioclient_mock: - aioclient_mock.get('http://api.macvendors.com/b8:27:eb', - status=500) - - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - - self.assertEqual(device.vendor, 'unknown') - - def test_mac_vendor_lookup_exception(self): - """Prevent another lookup if exception during API call.""" - mac = 'B8:27:EB:00:00:00' - - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, 'test', mac, 'Test name') - - with mock_aiohttp_client() as aioclient_mock: - aioclient_mock.get('http://api.macvendors.com/b8:27:eb', - exc=asyncio.TimeoutError()) - - run_coroutine_threadsafe(device.set_vendor_for_mac(), - self.hass.loop).result() - - self.assertEqual(device.vendor, 'unknown') - - def test_mac_vendor_lookup_on_see(self): - """Test if macvendor is looked up when device is seen.""" - mac = 'B8:27:EB:00:00:00' - vendor_string = 'Raspberry Pi Foundation' - - tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), 0, {}, []) - - with mock_aiohttp_client() as aioclient_mock: - aioclient_mock.get('http://api.macvendors.com/b8:27:eb', - text=vendor_string) - - run_coroutine_threadsafe( - tracker.async_see(mac=mac), self.hass.loop).result() - assert aioclient_mock.call_count == 1, \ - 'No http request for macvendor made!' - self.assertEqual(tracker.devices['b827eb000000'].vendor, vendor_string) - @patch( 'homeassistant.components.device_tracker.DeviceTracker.see') @patch( @@ -310,7 +189,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): def test_update_stale(self): """Test stalled update.""" - scanner = get_component('device_tracker.test').SCANNER + scanner = get_component(self.hass, 'device_tracker.test').SCANNER scanner.reset() scanner.come_home('DEV1') @@ -372,7 +251,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): hide_if_away=True) device_tracker.update_config(self.yaml_devices, dev_id, device) - scanner = get_component('device_tracker.test').SCANNER + scanner = get_component(self.hass, 'device_tracker.test').SCANNER scanner.reset() with assert_setup_component(1, device_tracker.DOMAIN): @@ -391,7 +270,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): hide_if_away=True) device_tracker.update_config(self.yaml_devices, dev_id, device) - scanner = get_component('device_tracker.test').SCANNER + scanner = get_component(self.hass, 'device_tracker.test').SCANNER scanner.reset() with assert_setup_component(1, device_tracker.DOMAIN): @@ -463,7 +342,6 @@ class TestComponentsDeviceTracker(unittest.TestCase): 'entity_id': 'device_tracker.hello', 'host_name': 'hello', 'mac': 'MAC_1', - 'vendor': 'unknown', } # pylint: disable=invalid-name @@ -495,9 +373,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): timedelta(seconds=0)) assert len(config) == 0 - @patch('homeassistant.components.device_tracker.Device' - '.set_vendor_for_mac', return_value=mock_coro()) - def test_see_state(self, mock_set_vendor): + def test_see_state(self): """Test device tracker see records state correctly.""" self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN, TEST_PLATFORM)) @@ -555,7 +431,7 @@ class TestComponentsDeviceTracker(unittest.TestCase): 'zone': zone_info }) - scanner = get_component('device_tracker.test').SCANNER + scanner = get_component(self.hass, 'device_tracker.test').SCANNER scanner.reset() scanner.come_home('dev1') @@ -671,7 +547,7 @@ def test_bad_platform(hass): async def test_adding_unknown_device_to_config(mock_device_tracker_conf, hass): """Test the adding of unknown devices to configuration file.""" - scanner = get_component('device_tracker.test').SCANNER + scanner = get_component(hass, 'device_tracker.test').SCANNER scanner.reset() scanner.come_home('DEV1') diff --git a/tests/components/device_tracker/test_locative.py b/tests/components/device_tracker/test_locative.py index 2476247e069..90adccf7703 100644 --- a/tests/components/device_tracker/test_locative.py +++ b/tests/components/device_tracker/test_locative.py @@ -19,7 +19,7 @@ def _url(data=None): @pytest.fixture -def locative_client(loop, hass, test_client): +def locative_client(loop, hass, aiohttp_client): """Locative mock client.""" assert loop.run_until_complete(async_setup_component( hass, device_tracker.DOMAIN, { @@ -29,7 +29,7 @@ def locative_client(loop, hass, test_client): })) with patch('homeassistant.components.device_tracker.update_config'): - yield loop.run_until_complete(test_client(hass.http.app)) + yield loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine diff --git a/tests/components/device_tracker/test_meraki.py b/tests/components/device_tracker/test_meraki.py index 74fc577bca8..925ba6d66db 100644 --- a/tests/components/device_tracker/test_meraki.py +++ b/tests/components/device_tracker/test_meraki.py @@ -13,7 +13,7 @@ from homeassistant.components.device_tracker.meraki import URL @pytest.fixture -def meraki_client(loop, hass, test_client): +def meraki_client(loop, hass, aiohttp_client): """Meraki mock client.""" assert loop.run_until_complete(async_setup_component( hass, device_tracker.DOMAIN, { @@ -25,7 +25,7 @@ def meraki_client(loop, hass, test_client): } })) - yield loop.run_until_complete(test_client(hass.http.app)) + yield loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine diff --git a/tests/components/device_tracker/test_owntracks_http.py b/tests/components/device_tracker/test_owntracks_http.py index be8bdd94ecc..d7b48cafe46 100644 --- a/tests/components/device_tracker/test_owntracks_http.py +++ b/tests/components/device_tracker/test_owntracks_http.py @@ -10,7 +10,7 @@ from tests.common import mock_coro, mock_component @pytest.fixture -def mock_client(hass, test_client): +def mock_client(hass, aiohttp_client): """Start the Hass HTTP component.""" mock_component(hass, 'group') mock_component(hass, 'zone') @@ -22,7 +22,7 @@ def mock_client(hass, test_client): 'platform': 'owntracks_http' } })) - return hass.loop.run_until_complete(test_client(hass.http.app)) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @pytest.fixture diff --git a/tests/components/device_tracker/test_unifi_direct.py b/tests/components/device_tracker/test_unifi_direct.py index 8bc3a60146c..ccfa59404a1 100644 --- a/tests/components/device_tracker/test_unifi_direct.py +++ b/tests/components/device_tracker/test_unifi_direct.py @@ -71,7 +71,7 @@ class TestComponentsDeviceTrackerUnifiDirect(unittest.TestCase): @patch('pexpect.pxssh.pxssh') def test_get_device_name(self, mock_ssh): - """"Testing MAC matching.""" + """Testing MAC matching.""" conf_dict = { DOMAIN: { CONF_PLATFORM: 'unifi_direct', @@ -95,7 +95,7 @@ class TestComponentsDeviceTrackerUnifiDirect(unittest.TestCase): @patch('pexpect.pxssh.pxssh.logout') @patch('pexpect.pxssh.pxssh.login') def test_failed_to_log_in(self, mock_login, mock_logout): - """"Testing exception at login results in False.""" + """Testing exception at login results in False.""" from pexpect import exceptions conf_dict = { @@ -120,7 +120,7 @@ class TestComponentsDeviceTrackerUnifiDirect(unittest.TestCase): @patch('pexpect.pxssh.pxssh.sendline') def test_to_get_update(self, mock_sendline, mock_prompt, mock_login, mock_logout): - """"Testing exception in get_update matching.""" + """Testing exception in get_update matching.""" conf_dict = { DOMAIN: { CONF_PLATFORM: 'unifi_direct', diff --git a/tests/components/device_tracker/test_xiaomi.py b/tests/components/device_tracker/test_xiaomi.py index 19f25b514db..bdd921f395f 100644 --- a/tests/components/device_tracker/test_xiaomi.py +++ b/tests/components/device_tracker/test_xiaomi.py @@ -210,7 +210,7 @@ class TestXiaomiDeviceScanner(unittest.TestCase): @patch('requests.get', side_effect=mocked_requests) @patch('requests.post', side_effect=mocked_requests) def test_invalid_credential(self, mock_get, mock_post): - """"Testing invalid credential handling.""" + """Testing invalid credential handling.""" config = { DOMAIN: xiaomi.PLATFORM_SCHEMA({ CONF_PLATFORM: xiaomi.DOMAIN, @@ -224,7 +224,7 @@ class TestXiaomiDeviceScanner(unittest.TestCase): @patch('requests.get', side_effect=mocked_requests) @patch('requests.post', side_effect=mocked_requests) def test_valid_credential(self, mock_get, mock_post): - """"Testing valid refresh.""" + """Testing valid refresh.""" config = { DOMAIN: xiaomi.PLATFORM_SCHEMA({ CONF_PLATFORM: xiaomi.DOMAIN, @@ -244,7 +244,7 @@ class TestXiaomiDeviceScanner(unittest.TestCase): @patch('requests.get', side_effect=mocked_requests) @patch('requests.post', side_effect=mocked_requests) def test_token_timed_out(self, mock_get, mock_post): - """"Testing refresh with a timed out token. + """Testing refresh with a timed out token. New token is requested and list is downloaded a second time. """ diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 91988a76212..1617f327d27 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -118,7 +118,7 @@ def hass_hue(loop, hass): @pytest.fixture -def hue_client(loop, hass_hue, test_client): +def hue_client(loop, hass_hue, aiohttp_client): """Create web client for emulated hue api.""" web_app = hass_hue.http.app config = Config(None, { @@ -135,7 +135,7 @@ def hue_client(loop, hass_hue, test_client): HueOneLightStateView(config).register(web_app.router) HueOneLightChangeView(config).register(web_app.router) - return loop.run_until_complete(test_client(web_app)) + return loop.run_until_complete(aiohttp_client(web_app)) @asyncio.coroutine diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 06613f1336a..2f443eb5d6e 100644 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -3,7 +3,7 @@ import json from unittest.mock import patch, Mock, mock_open -from homeassistant.components.emulated_hue import Config, _LOGGER +from homeassistant.components.emulated_hue import Config def test_config_google_home_entity_id_to_number(): @@ -112,17 +112,3 @@ def test_config_alexa_entity_id_to_number(): entity_id = conf.number_to_entity_id('light.test') assert entity_id == 'light.test' - - -def test_warning_config_google_home_listen_port(): - """Test we warn when non-default port is used for Google Home.""" - with patch.object(_LOGGER, 'warning') as mock_warn: - Config(None, { - 'type': 'google_home', - 'host_ip': '123.123.123.123', - 'listen_port': 8300 - }) - - assert mock_warn.called - assert mock_warn.mock_calls[0][1][0] == \ - "When targeting Google Home, listening port has to be port 80" diff --git a/tests/components/fan/test_mqtt.py b/tests/components/fan/test_mqtt.py index ec68492ed1e..9060d7b9986 100644 --- a/tests/components/fan/test_mqtt.py +++ b/tests/components/fan/test_mqtt.py @@ -18,7 +18,7 @@ class TestMqttFan(unittest.TestCase): self.mock_publish = mock_mqtt_component(self.hass) def tearDown(self): # pylint: disable=invalid-name - """"Stop everything that was started.""" + """Stop everything that was started.""" self.hass.stop() def test_default_availability_payload(self): diff --git a/tests/components/fan/test_template.py b/tests/components/fan/test_template.py new file mode 100644 index 00000000000..53eb9e8e2d4 --- /dev/null +++ b/tests/components/fan/test_template.py @@ -0,0 +1,644 @@ +"""The tests for the Template fan platform.""" +import logging + +from homeassistant.core import callback +from homeassistant import setup +import homeassistant.components as components +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.components.fan import ( + ATTR_SPEED, ATTR_OSCILLATING, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, + ATTR_DIRECTION, DIRECTION_FORWARD, DIRECTION_REVERSE) + +from tests.common import ( + get_test_home_assistant, assert_setup_component) +_LOGGER = logging.getLogger(__name__) + + +_TEST_FAN = 'fan.test_fan' +# Represent for fan's state +_STATE_INPUT_BOOLEAN = 'input_boolean.state' +# Represent for fan's speed +_SPEED_INPUT_SELECT = 'input_select.speed' +# Represent for fan's oscillating +_OSC_INPUT = 'input_select.osc' +# Represent for fan's direction +_DIRECTION_INPUT_SELECT = 'input_select.direction' + + +class TestTemplateFan: + """Test the Template light.""" + + hass = None + calls = None + # pylint: disable=invalid-name + + def setup_method(self, method): + """Setup.""" + self.hass = get_test_home_assistant() + + self.calls = [] + + @callback + def record_call(service): + """Track function calls..""" + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + # Configuration tests # + def test_missing_optional_config(self): + """Test: missing optional template is ok.""" + with assert_setup_component(1, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': "{{ 'on' }}", + + 'turn_on': { + 'service': 'script.fan_on' + }, + 'turn_off': { + 'service': 'script.fan_off' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self._verify(STATE_ON, None, None, None) + + def test_missing_value_template_config(self): + """Test: missing 'value_template' will fail.""" + with assert_setup_component(0, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'turn_on': { + 'service': 'script.fan_on' + }, + 'turn_off': { + 'service': 'script.fan_off' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_missing_turn_on_config(self): + """Test: missing 'turn_on' will fail.""" + with assert_setup_component(0, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': "{{ 'on' }}", + 'turn_off': { + 'service': 'script.fan_off' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_missing_turn_off_config(self): + """Test: missing 'turn_off' will fail.""" + with assert_setup_component(0, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': "{{ 'on' }}", + 'turn_on': { + 'service': 'script.fan_on' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_invalid_config(self): + """Test: missing 'turn_off' will fail.""" + with assert_setup_component(0, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': "{{ 'on' }}", + 'turn_on': { + 'service': 'script.fan_on' + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + # End of configuration tests # + + # Template tests # + def test_templates_with_entities(self): + """Test tempalates with values from other entities.""" + value_template = """ + {% if is_state('input_boolean.state', 'True') %} + {{ 'on' }} + {% else %} + {{ 'off' }} + {% endif %} + """ + + with assert_setup_component(1, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': value_template, + 'speed_template': + "{{ states('input_select.speed') }}", + 'oscillating_template': + "{{ states('input_select.osc') }}", + 'direction_template': + "{{ states('input_select.direction') }}", + 'turn_on': { + 'service': 'script.fan_on' + }, + 'turn_off': { + 'service': 'script.fan_off' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self._verify(STATE_OFF, None, None, None) + + self.hass.states.set(_STATE_INPUT_BOOLEAN, True) + self.hass.states.set(_SPEED_INPUT_SELECT, SPEED_MEDIUM) + self.hass.states.set(_OSC_INPUT, 'True') + self.hass.states.set(_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD) + self.hass.block_till_done() + + self._verify(STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD) + + def test_templates_with_valid_values(self): + """Test templates with valid values.""" + with assert_setup_component(1, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': + "{{ 'on' }}", + 'speed_template': + "{{ 'medium' }}", + 'oscillating_template': + "{{ 1 == 1 }}", + 'direction_template': + "{{ 'forward' }}", + + 'turn_on': { + 'service': 'script.fan_on' + }, + 'turn_off': { + 'service': 'script.fan_off' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self._verify(STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD) + + def test_templates_invalid_values(self): + """Test templates with invalid values.""" + with assert_setup_component(1, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': + "{{ 'abc' }}", + 'speed_template': + "{{ '0' }}", + 'oscillating_template': + "{{ 'xyz' }}", + 'direction_template': + "{{ 'right' }}", + + 'turn_on': { + 'service': 'script.fan_on' + }, + 'turn_off': { + 'service': 'script.fan_off' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self._verify(STATE_OFF, None, None, None) + + # End of template tests # + + # Function tests # + def test_on_off(self): + """Test turn on and turn off.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON + self._verify(STATE_ON, None, None, None) + + # Turn off fan + components.fan.turn_off(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_OFF + self._verify(STATE_OFF, None, None, None) + + def test_on_with_speed(self): + """Test turn on with speed.""" + self._register_components() + + # Turn on fan with high speed + components.fan.turn_on(self.hass, _TEST_FAN, SPEED_HIGH) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH + self._verify(STATE_ON, SPEED_HIGH, None, None) + + def test_set_speed(self): + """Test set valid speed.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's speed to high + components.fan.set_speed(self.hass, _TEST_FAN, SPEED_HIGH) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH + self._verify(STATE_ON, SPEED_HIGH, None, None) + + # Set fan's speed to medium + components.fan.set_speed(self.hass, _TEST_FAN, SPEED_MEDIUM) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_MEDIUM + self._verify(STATE_ON, SPEED_MEDIUM, None, None) + + def test_set_invalid_speed_from_initial_stage(self): + """Test set invalid speed when fan is in initial state.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's speed to 'invalid' + components.fan.set_speed(self.hass, _TEST_FAN, 'invalid') + self.hass.block_till_done() + + # verify speed is unchanged + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == '' + self._verify(STATE_ON, None, None, None) + + def test_set_invalid_speed(self): + """Test set invalid speed when fan has valid speed.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's speed to high + components.fan.set_speed(self.hass, _TEST_FAN, SPEED_HIGH) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH + self._verify(STATE_ON, SPEED_HIGH, None, None) + + # Set fan's speed to 'invalid' + components.fan.set_speed(self.hass, _TEST_FAN, 'invalid') + self.hass.block_till_done() + + # verify speed is unchanged + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH + self._verify(STATE_ON, SPEED_HIGH, None, None) + + def test_custom_speed_list(self): + """Test set custom speed list.""" + self._register_components(['1', '2', '3']) + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's speed to '1' + components.fan.set_speed(self.hass, _TEST_FAN, '1') + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == '1' + self._verify(STATE_ON, '1', None, None) + + # Set fan's speed to 'medium' which is invalid + components.fan.set_speed(self.hass, _TEST_FAN, SPEED_MEDIUM) + self.hass.block_till_done() + + # verify that speed is unchanged + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == '1' + self._verify(STATE_ON, '1', None, None) + + def test_set_osc(self): + """Test set oscillating.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's osc to True + components.fan.oscillate(self.hass, _TEST_FAN, True) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_OSC_INPUT).state == 'True' + self._verify(STATE_ON, None, True, None) + + # Set fan's osc to False + components.fan.oscillate(self.hass, _TEST_FAN, False) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_OSC_INPUT).state == 'False' + self._verify(STATE_ON, None, False, None) + + def test_set_invalid_osc_from_initial_state(self): + """Test set invalid oscillating when fan is in initial state.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's osc to 'invalid' + components.fan.oscillate(self.hass, _TEST_FAN, 'invalid') + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_OSC_INPUT).state == '' + self._verify(STATE_ON, None, None, None) + + def test_set_invalid_osc(self): + """Test set invalid oscillating when fan has valid osc.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's osc to True + components.fan.oscillate(self.hass, _TEST_FAN, True) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_OSC_INPUT).state == 'True' + self._verify(STATE_ON, None, True, None) + + # Set fan's osc to False + components.fan.oscillate(self.hass, _TEST_FAN, None) + self.hass.block_till_done() + + # verify osc is unchanged + assert self.hass.states.get(_OSC_INPUT).state == 'True' + self._verify(STATE_ON, None, True, None) + + def test_set_direction(self): + """Test set valid direction.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's direction to forward + components.fan.set_direction(self.hass, _TEST_FAN, DIRECTION_FORWARD) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state \ + == DIRECTION_FORWARD + self._verify(STATE_ON, None, None, DIRECTION_FORWARD) + + # Set fan's direction to reverse + components.fan.set_direction(self.hass, _TEST_FAN, DIRECTION_REVERSE) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state \ + == DIRECTION_REVERSE + self._verify(STATE_ON, None, None, DIRECTION_REVERSE) + + def test_set_invalid_direction_from_initial_stage(self): + """Test set invalid direction when fan is in initial state.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's direction to 'invalid' + components.fan.set_direction(self.hass, _TEST_FAN, 'invalid') + self.hass.block_till_done() + + # verify direction is unchanged + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state == '' + self._verify(STATE_ON, None, None, None) + + def test_set_invalid_direction(self): + """Test set invalid direction when fan has valid direction.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's direction to forward + components.fan.set_direction(self.hass, _TEST_FAN, DIRECTION_FORWARD) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state == \ + DIRECTION_FORWARD + self._verify(STATE_ON, None, None, DIRECTION_FORWARD) + + # Set fan's direction to 'invalid' + components.fan.set_direction(self.hass, _TEST_FAN, 'invalid') + self.hass.block_till_done() + + # verify direction is unchanged + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state == \ + DIRECTION_FORWARD + self._verify(STATE_ON, None, None, DIRECTION_FORWARD) + + def _verify(self, expected_state, expected_speed, expected_oscillating, + expected_direction): + """Verify fan's state, speed and osc.""" + state = self.hass.states.get(_TEST_FAN) + attributes = state.attributes + assert state.state == expected_state + assert attributes.get(ATTR_SPEED, None) == expected_speed + assert attributes.get(ATTR_OSCILLATING, None) == expected_oscillating + assert attributes.get(ATTR_DIRECTION, None) == expected_direction + + def _register_components(self, speed_list=None): + """Register basic components for testing.""" + with assert_setup_component(1, 'input_boolean'): + assert setup.setup_component( + self.hass, + 'input_boolean', + {'input_boolean': {'state': None}} + ) + + with assert_setup_component(3, 'input_select'): + assert setup.setup_component(self.hass, 'input_select', { + 'input_select': { + 'speed': { + 'name': 'Speed', + 'options': ['', SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, + '1', '2', '3'] + }, + + 'osc': { + 'name': 'oscillating', + 'options': ['', 'True', 'False'] + }, + + 'direction': { + 'name': 'Direction', + 'options': ['', DIRECTION_FORWARD, DIRECTION_REVERSE] + }, + } + }) + + with assert_setup_component(1, 'fan'): + value_template = """ + {% if is_state('input_boolean.state', 'on') %} + {{ 'on' }} + {% else %} + {{ 'off' }} + {% endif %} + """ + + test_fan_config = { + 'value_template': value_template, + 'speed_template': + "{{ states('input_select.speed') }}", + 'oscillating_template': + "{{ states('input_select.osc') }}", + 'direction_template': + "{{ states('input_select.direction') }}", + + 'turn_on': { + 'service': 'input_boolean.turn_on', + 'entity_id': _STATE_INPUT_BOOLEAN + }, + 'turn_off': { + 'service': 'input_boolean.turn_off', + 'entity_id': _STATE_INPUT_BOOLEAN + }, + 'set_speed': { + 'service': 'input_select.select_option', + + 'data_template': { + 'entity_id': _SPEED_INPUT_SELECT, + 'option': '{{ speed }}' + } + }, + 'set_oscillating': { + 'service': 'input_select.select_option', + + 'data_template': { + 'entity_id': _OSC_INPUT, + 'option': '{{ oscillating }}' + } + }, + 'set_direction': { + 'service': 'input_select.select_option', + + 'data_template': { + 'entity_id': _DIRECTION_INPUT_SELECT, + 'option': '{{ direction }}' + } + } + } + + if speed_list: + test_fan_config['speeds'] = speed_list + + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': test_fan_config + } + } + }) + + self.hass.start() + self.hass.block_till_done() diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index cb319b67bb2..d45680d132e 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -27,7 +27,7 @@ AUTH_HEADER = {AUTHORIZATION: 'Bearer {}'.format(ACCESS_TOKEN)} @pytest.fixture -def assistant_client(loop, hass, test_client): +def assistant_client(loop, hass, aiohttp_client): """Create web client for the Google Assistant API.""" loop.run_until_complete( setup.async_setup_component(hass, 'google_assistant', { @@ -44,7 +44,7 @@ def assistant_client(loop, hass, test_client): } })) - return loop.run_until_complete(test_client(hass.http.app)) + return loop.run_until_complete(aiohttp_client(hass.http.app)) @pytest.fixture diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 8d139fa8211..cdaf4200c97 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1,4 +1,5 @@ """Test Google Smart Home.""" +from homeassistant.core import State from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) from homeassistant.setup import async_setup_component @@ -20,7 +21,7 @@ async def test_sync_message(hass): light = DemoLight( None, 'Demo Light', state=False, - rgb=[237, 224, 33] + hs_color=(180, 75), ) light.hass = hass light.entity_id = 'light.demo_light' @@ -73,7 +74,7 @@ async def test_sync_message(hass): 'willReportState': False, 'attributes': { 'colorModel': 'rgb', - 'temperatureMinK': 6493, + 'temperatureMinK': 6535, 'temperatureMaxK': 2000, }, 'roomHint': 'Living Room' @@ -87,7 +88,7 @@ async def test_query_message(hass): light = DemoLight( None, 'Demo Light', state=False, - rgb=[237, 224, 33] + hs_color=(180, 75), ) light.hass = hass light.entity_id = 'light.demo_light' @@ -96,7 +97,7 @@ async def test_query_message(hass): light2 = DemoLight( None, 'Another Light', state=True, - rgb=[237, 224, 33], + hs_color=(180, 75), ct=400, brightness=78, ) @@ -136,7 +137,7 @@ async def test_query_message(hass): 'online': True, 'brightness': 30, 'color': { - 'spectrumRGB': 15589409, + 'spectrumRGB': 4194303, 'temperature': 2500, } }, @@ -196,7 +197,7 @@ async def test_execute(hass): "online": True, 'brightness': 20, 'color': { - 'spectrumRGB': 15589409, + 'spectrumRGB': 16773155, 'temperature': 2631, }, } @@ -244,3 +245,70 @@ async def test_raising_error_trait(hass): }] } } + + +def test_serialize_input_boolean(): + """Test serializing an input boolean entity.""" + state = State('input_boolean.bla', 'on') + entity = sh._GoogleEntity(None, BASIC_CONFIG, state) + assert entity.sync_serialize() == { + 'id': 'input_boolean.bla', + 'attributes': {}, + 'name': {'name': 'bla'}, + 'traits': ['action.devices.traits.OnOff'], + 'type': 'action.devices.types.SWITCH', + 'willReportState': False, + } + + +async def test_unavailable_state_doesnt_sync(hass): + """Test that an unavailable entity does not sync over.""" + light = DemoLight( + None, 'Demo Light', + state=False, + ) + light.hass = hass + light.entity_id = 'light.demo_light' + light._available = False + await light.async_update_ha_state() + + result = await sh.async_handle_message(hass, BASIC_CONFIG, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'agentUserId': 'test-agent', + 'devices': [] + } + } + + +async def test_empty_name_doesnt_sync(hass): + """Test that an entity with empty name does not sync over.""" + light = DemoLight( + None, ' ', + state=False, + ) + light.hass = hass + light.entity_id = 'light.demo_light' + await light.async_update_ha_state() + + result = await sh.async_handle_message(hass, BASIC_CONFIG, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'agentUserId': 'test-agent', + 'devices': [] + } + } diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 4ffb273662e..e6336e05246 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -361,12 +361,10 @@ async def test_color_spectrum_light(hass): """Test ColorSpectrum trait support for light domain.""" assert not trait.ColorSpectrumTrait.supported(light.DOMAIN, 0) assert trait.ColorSpectrumTrait.supported(light.DOMAIN, - light.SUPPORT_RGB_COLOR) - assert trait.ColorSpectrumTrait.supported(light.DOMAIN, - light.SUPPORT_XY_COLOR) + light.SUPPORT_COLOR) trt = trait.ColorSpectrumTrait(State('light.bla', STATE_ON, { - light.ATTR_RGB_COLOR: [255, 10, 10] + light.ATTR_HS_COLOR: (0, 94), })) assert trt.sync_attributes() == { @@ -375,7 +373,7 @@ async def test_color_spectrum_light(hass): assert trt.query_attributes() == { 'color': { - 'spectrumRGB': 16714250 + 'spectrumRGB': 16715535 } } @@ -399,7 +397,7 @@ async def test_color_spectrum_light(hass): assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'light.bla', - light.ATTR_RGB_COLOR: [16, 16, 255] + light.ATTR_HS_COLOR: (240, 93.725), } diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 56d6cbe666e..9f20efc08a5 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -26,7 +26,7 @@ def hassio_env(): @pytest.fixture -def hassio_client(hassio_env, hass, test_client): +def hassio_client(hassio_env, hass, aiohttp_client): """Create mock hassio http client.""" with patch('homeassistant.components.hassio.HassIO.update_hass_api', Mock(return_value=mock_coro({"result": "ok"}))), \ @@ -38,7 +38,7 @@ def hassio_client(hassio_env, hass, test_client): 'api_password': API_PASSWORD } })) - yield hass.loop.run_until_complete(test_client(hass.http.app)) + yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @pytest.fixture diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index ed425ad8cca..ac90deb9f73 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -48,7 +48,7 @@ def test_auth_required_forward_request(hassio_client): @pytest.mark.parametrize( 'build_type', [ 'es5/index.html', 'es5/hassio-app.html', 'latest/index.html', - 'latest/hassio-app.html' + 'latest/hassio-app.html', 'es5/some-chunk.js', 'es5/app.js', ]) def test_forward_request_no_auth_for_panel(hassio_client, build_type): """Test no auth needed for .""" diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index e17419e7fd5..f67a6cbccec 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -9,6 +9,12 @@ from homeassistant.components.hassio import async_check_config from tests.common import mock_coro +MOCK_ENVIRON = { + 'HASSIO': '127.0.0.1', + 'HASSIO_TOKEN': 'abcdefgh', +} + + @asyncio.coroutine def test_setup_api_ping(hass, aioclient_mock): """Test setup with API ping.""" @@ -18,7 +24,7 @@ def test_setup_api_ping(hass, aioclient_mock): "http://127.0.0.1/homeassistant/info", json={ 'result': 'ok', 'data': {'last_version': '10.0'}}) - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', {}) assert result @@ -38,7 +44,7 @@ def test_setup_api_push_api_data(hass, aioclient_mock): aioclient_mock.post( "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', { 'http': { 'api_password': "123456", @@ -66,7 +72,7 @@ def test_setup_api_push_api_data_server_host(hass, aioclient_mock): aioclient_mock.post( "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', { 'http': { 'api_password': "123456", @@ -95,7 +101,7 @@ def test_setup_api_push_api_data_default(hass, aioclient_mock): aioclient_mock.post( "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', { 'http': {}, 'hassio': {} @@ -119,7 +125,7 @@ def test_setup_core_push_timezone(hass, aioclient_mock): aioclient_mock.post( "http://127.0.0.1/supervisor/options", json={'result': 'ok'}) - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', { 'hassio': {}, 'homeassistant': { @@ -143,7 +149,7 @@ def test_setup_hassio_no_additional_data(hass, aioclient_mock): aioclient_mock.get( "http://127.0.0.1/homeassistant/info", json={'result': 'ok'}) - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ + with patch.dict(os.environ, MOCK_ENVIRON), \ patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}): result = yield from async_setup_component(hass, 'hassio', { 'hassio': {}, @@ -165,7 +171,7 @@ def test_fail_setup_without_environ_var(hass): @asyncio.coroutine def test_fail_setup_cannot_connect(hass): """Fail setup if cannot connect.""" - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ + with patch.dict(os.environ, MOCK_ENVIRON), \ patch('homeassistant.components.hassio.HassIO.is_connected', Mock(return_value=mock_coro(None))): result = yield from async_setup_component(hass, 'hassio', {}) diff --git a/tests/components/homekit/__init__.py b/tests/components/homekit/__init__.py deleted file mode 100644 index 61a60cee2ac..00000000000 --- a/tests/components/homekit/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The tests for the homekit component.""" diff --git a/tests/components/homekit/common.py b/tests/components/homekit/common.py new file mode 100644 index 00000000000..915759f22d6 --- /dev/null +++ b/tests/components/homekit/common.py @@ -0,0 +1,8 @@ +"""Collection of fixtures and functions for the HomeKit tests.""" +from unittest.mock import patch + + +def patch_debounce(): + """Return patch for debounce method.""" + return patch('homeassistant.components.homekit.accessories.debounce', + lambda f: lambda *args, **kwargs: f(*args, **kwargs)) diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py new file mode 100644 index 00000000000..f7839265939 --- /dev/null +++ b/tests/components/homekit/conftest.py @@ -0,0 +1,16 @@ +"""HomeKit session fixtures.""" +from unittest.mock import patch + +import pytest + +from pyhap.accessory_driver import AccessoryDriver + + +@pytest.fixture(scope='session') +def hk_driver(): + """Return a custom AccessoryDriver instance for HomeKit accessory init.""" + with patch('pyhap.accessory_driver.Zeroconf'), \ + patch('pyhap.accessory_driver.AccessoryEncoder'), \ + patch('pyhap.accessory_driver.HAPServer'), \ + patch('pyhap.accessory_driver.AccessoryDriver.publish'): + return AccessoryDriver(pincode=b'123-45-678') diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 6f39a8c792b..711c38443f2 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -2,166 +2,154 @@ This includes tests for all mock object types. """ +from datetime import datetime, timedelta +from unittest.mock import patch, Mock -from unittest.mock import patch - -# pylint: disable=unused-import -from pyhap.loader import get_serv_loader, get_char_loader # noqa F401 +import pytest from homeassistant.components.homekit.accessories import ( - set_accessory_info, add_preload_service, override_properties, - HomeAccessory, HomeBridge) + debounce, HomeAccessory, HomeBridge, HomeDriver) from homeassistant.components.homekit.const import ( - SERV_ACCESSORY_INFO, SERV_BRIDGING_STATE, - CHAR_MODEL, CHAR_MANUFACTURER, CHAR_NAME, CHAR_SERIAL_NUMBER) - -from tests.mock.homekit import ( - get_patch_paths, mock_preload_service, - MockTypeLoader, MockAccessory, MockService, MockChar) - -PATH_SERV = 'pyhap.loader.get_serv_loader' -PATH_CHAR = 'pyhap.loader.get_char_loader' -PATH_ACC, _ = get_patch_paths() + BRIDGE_MODEL, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, CHAR_FIRMWARE_REVISION, + CHAR_MANUFACTURER, CHAR_MODEL, CHAR_NAME, CHAR_SERIAL_NUMBER, + MANUFACTURER, SERV_ACCESSORY_INFO) +from homeassistant.const import __version__, ATTR_NOW, EVENT_TIME_CHANGED +import homeassistant.util.dt as dt_util -@patch(PATH_CHAR, return_value=MockTypeLoader('char')) -@patch(PATH_SERV, return_value=MockTypeLoader('service')) -def test_add_preload_service(mock_serv, mock_char): - """Test method add_preload_service. +async def test_debounce(hass): + """Test add_timeout decorator function.""" + def demo_func(*args): + nonlocal arguments, counter + counter += 1 + arguments = args - The methods 'get_serv_loader' and 'get_char_loader' are mocked. - """ - acc = MockAccessory('Accessory') - serv = add_preload_service(acc, 'TestService', - ['TestChar', 'TestChar2'], - ['TestOptChar', 'TestOptChar2']) + arguments = None + counter = 0 + mock = Mock(hass=hass, debounce={}) - assert serv.display_name == 'TestService' - assert len(serv.characteristics) == 2 - assert len(serv.opt_characteristics) == 2 + debounce_demo = debounce(demo_func) + assert debounce_demo.__name__ == 'demo_func' + now = datetime(2018, 1, 1, 20, 0, 0, tzinfo=dt_util.UTC) - acc.services = [] - serv = add_preload_service(acc, 'TestService') + with patch('homeassistant.util.dt.utcnow', return_value=now): + await hass.async_add_job(debounce_demo, mock, 'value') + hass.bus.async_fire( + EVENT_TIME_CHANGED, {ATTR_NOW: now + timedelta(seconds=3)}) + await hass.async_block_till_done() + assert counter == 1 + assert len(arguments) == 2 - assert not serv.characteristics - assert not serv.opt_characteristics + with patch('homeassistant.util.dt.utcnow', return_value=now): + await hass.async_add_job(debounce_demo, mock, 'value') + await hass.async_add_job(debounce_demo, mock, 'value') - acc.services = [] - serv = add_preload_service(acc, 'TestService', - 'TestChar', 'TestOptChar') - - assert len(serv.characteristics) == 1 - assert len(serv.opt_characteristics) == 1 - - assert serv.characteristics[0].display_name == 'TestChar' - assert serv.opt_characteristics[0].display_name == 'TestOptChar' + hass.bus.async_fire( + EVENT_TIME_CHANGED, {ATTR_NOW: now + timedelta(seconds=3)}) + await hass.async_block_till_done() + assert counter == 2 -def test_override_properties(): - """Test override of characteristic properties with MockChar.""" - char = MockChar('TestChar') - new_prop = {1: 'Test', 2: 'Demo'} - override_properties(char, new_prop) - - assert char.properties == new_prop - - -def test_set_accessory_info(): - """Test setting of basic accessory information with MockAccessory.""" - acc = MockAccessory('Accessory') - set_accessory_info(acc, 'name', 'model', 'manufacturer', '0000') +async def test_home_accessory(hass, hk_driver): + """Test HomeAccessory class.""" + entity_id = 'homekit.accessory' + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = HomeAccessory(hass, hk_driver, 'Home Accessory', + entity_id, 2, None) + assert acc.hass == hass + assert acc.display_name == 'Home Accessory' + assert acc.aid == 2 + assert acc.category == 1 # Category.OTHER assert len(acc.services) == 1 - serv = acc.services[0] - + serv = acc.services[0] # SERV_ACCESSORY_INFO assert serv.display_name == SERV_ACCESSORY_INFO - assert len(serv.characteristics) == 4 - chars = serv.characteristics + assert serv.get_characteristic(CHAR_NAME).value == 'Home Accessory' + assert serv.get_characteristic(CHAR_MANUFACTURER).value == MANUFACTURER + assert serv.get_characteristic(CHAR_MODEL).value == 'Homekit' + assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == \ + 'homekit.accessory' - assert chars[0].display_name == CHAR_NAME - assert chars[0].value == 'name' - assert chars[1].display_name == CHAR_MODEL - assert chars[1].value == 'model' - assert chars[2].display_name == CHAR_MANUFACTURER - assert chars[2].value == 'manufacturer' - assert chars[3].display_name == CHAR_SERIAL_NUMBER - assert chars[3].value == '0000' + hass.states.async_set(entity_id, 'on') + await hass.async_block_till_done() + with patch('homeassistant.components.homekit.accessories.' + 'HomeAccessory.update_state') as mock_update_state: + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + mock_update_state.assert_called_with(state) + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert mock_update_state.call_count == 1 + + with pytest.raises(NotImplementedError): + acc.update_state('new_state') + + # Test model name from domain + entity_id = 'test_model.demo' + acc = HomeAccessory('hass', hk_driver, 'test_name', entity_id, 2, None) + serv = acc.services[0] # SERV_ACCESSORY_INFO + assert serv.get_characteristic(CHAR_MODEL).value == 'Test Model' -@patch(PATH_ACC, side_effect=mock_preload_service) -def test_home_accessory(mock_pre_serv): - """Test initializing a HomeAccessory object.""" - acc = HomeAccessory('TestAccessory', 'test.accessory', 'WINDOW') - - assert acc.display_name == 'TestAccessory' - assert acc.category == 13 # Category.WINDOW - assert len(acc.services) == 1 - - serv = acc.services[0] +def test_home_bridge(hk_driver): + """Test HomeBridge class.""" + bridge = HomeBridge('hass', hk_driver) + assert bridge.hass == 'hass' + assert bridge.display_name == BRIDGE_NAME + assert bridge.category == 2 # Category.BRIDGE + assert len(bridge.services) == 1 + serv = bridge.services[0] # SERV_ACCESSORY_INFO assert serv.display_name == SERV_ACCESSORY_INFO - char_model = serv.get_characteristic(CHAR_MODEL) - assert char_model.get_value() == 'test.accessory' + assert serv.get_characteristic(CHAR_NAME).value == BRIDGE_NAME + assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == __version__ + assert serv.get_characteristic(CHAR_MANUFACTURER).value == MANUFACTURER + assert serv.get_characteristic(CHAR_MODEL).value == BRIDGE_MODEL + assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == \ + BRIDGE_SERIAL_NUMBER + + bridge = HomeBridge('hass', hk_driver, 'test_name') + assert bridge.display_name == 'test_name' + assert len(bridge.services) == 1 + serv = bridge.services[0] # SERV_ACCESSORY_INFO + + # setup_message + bridge.setup_message() -@patch(PATH_ACC, side_effect=mock_preload_service) -def test_home_bridge(mock_pre_serv): - """Test initializing a HomeBridge object.""" - bridge = HomeBridge('TestBridge', 'test.bridge', b'123-45-678') +def test_home_driver(): + """Test HomeDriver class.""" + ip_address = '127.0.0.1' + port = 51826 + path = '.homekit.state' + pin = b'123-45-678' - assert bridge.display_name == 'TestBridge' - assert bridge.pincode == b'123-45-678' - assert len(bridge.services) == 2 + with patch('pyhap.accessory_driver.AccessoryDriver.__init__') \ + as mock_driver: + driver = HomeDriver('hass', address=ip_address, port=port, + persist_file=path) - assert bridge.services[0].display_name == SERV_ACCESSORY_INFO - assert bridge.services[1].display_name == SERV_BRIDGING_STATE + mock_driver.assert_called_with(address=ip_address, port=port, + persist_file=path) + driver.state = Mock(pincode=pin) - char_model = bridge.services[0].get_characteristic(CHAR_MODEL) - assert char_model.get_value() == 'test.bridge' + # pair + with patch('pyhap.accessory_driver.AccessoryDriver.pair') as mock_pair, \ + patch('homeassistant.components.homekit.accessories.' + 'dismiss_setup_message') as mock_dissmiss_msg: + driver.pair('client_uuid', 'client_public') + mock_pair.assert_called_with('client_uuid', 'client_public') + mock_dissmiss_msg.assert_called_with('hass') -def test_mock_accessory(): - """Test attributes and functions of a MockAccessory.""" - acc = MockAccessory('TestAcc') - serv = MockService('TestServ') - acc.add_service(serv) + # unpair + with patch('pyhap.accessory_driver.AccessoryDriver.unpair') \ + as mock_unpair, \ + patch('homeassistant.components.homekit.accessories.' + 'show_setup_message') as mock_show_msg: + driver.unpair('client_uuid') - assert acc.display_name == 'TestAcc' - assert len(acc.services) == 1 - - assert acc.get_service('TestServ') == serv - assert acc.get_service('NewServ').display_name == 'NewServ' - assert len(acc.services) == 2 - - -def test_mock_service(): - """Test attributes and functions of a MockService.""" - serv = MockService('TestServ') - char = MockChar('TestChar') - opt_char = MockChar('TestOptChar') - serv.add_characteristic(char) - serv.add_opt_characteristic(opt_char) - - assert serv.display_name == 'TestServ' - assert len(serv.characteristics) == 1 - assert len(serv.opt_characteristics) == 1 - - assert serv.get_characteristic('TestChar') == char - assert serv.get_characteristic('TestOptChar') == opt_char - assert serv.get_characteristic('NewChar').display_name == 'NewChar' - assert len(serv.characteristics) == 2 - - -def test_mock_char(): - """Test attributes and functions of a MockChar.""" - def callback_method(value): - """Provide a callback options for 'set_value' method.""" - assert value == 'With callback' - - char = MockChar('TestChar') - char.set_value('Value') - - assert char.display_name == 'TestChar' - assert char.get_value() == 'Value' - - char.setter_callback = callback_method - char.set_value('With callback') + mock_unpair.assert_called_with('client_uuid') + mock_show_msg.assert_called_with('hass', pin) diff --git a/tests/components/homekit/test_covers.py b/tests/components/homekit/test_covers.py deleted file mode 100644 index fe0ede5d8fb..00000000000 --- a/tests/components/homekit/test_covers.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Test different accessory types: Covers.""" -import unittest -from unittest.mock import patch - -from homeassistant.core import callback -from homeassistant.components.cover import ( - ATTR_POSITION, ATTR_CURRENT_POSITION) -from homeassistant.components.homekit.covers import Window -from homeassistant.const import ( - STATE_UNKNOWN, STATE_OPEN, - ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE) - -from tests.common import get_test_home_assistant -from tests.mock.homekit import get_patch_paths, mock_preload_service - -PATH_ACC, PATH_FILE = get_patch_paths('covers') - - -class TestHomekitSensors(unittest.TestCase): - """Test class for all accessory types regarding covers.""" - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.events = [] - - @callback - def record_event(event): - """Track called event.""" - self.events.append(event) - - self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - def test_window_set_cover_position(self): - """Test if accessory and HA are updated accordingly.""" - window_cover = 'cover.window' - - with patch(PATH_ACC, side_effect=mock_preload_service): - with patch(PATH_FILE, side_effect=mock_preload_service): - acc = Window(self.hass, window_cover, 'Cover') - acc.run() - - self.assertEqual(acc.char_current_position.value, 0) - self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 0) - - self.hass.states.set(window_cover, STATE_UNKNOWN, - {ATTR_CURRENT_POSITION: None}) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_position.value, 0) - self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 0) - - self.hass.states.set(window_cover, STATE_OPEN, - {ATTR_CURRENT_POSITION: 50}) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_position.value, 50) - self.assertEqual(acc.char_target_position.value, 50) - self.assertEqual(acc.char_position_state.value, 2) - - # Set from HomeKit - acc.char_target_position.set_value(25) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'set_cover_position') - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA][ATTR_POSITION], 25) - - self.assertEqual(acc.char_current_position.value, 50) - self.assertEqual(acc.char_target_position.value, 25) - self.assertEqual(acc.char_position_state.value, 0) - - # Set from HomeKit - acc.char_target_position.set_value(75) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'set_cover_position') - self.assertEqual( - self.events[1].data[ATTR_SERVICE_DATA][ATTR_POSITION], 75) - - self.assertEqual(acc.char_current_position.value, 50) - self.assertEqual(acc.char_target_position.value, 75) - self.assertEqual(acc.char_position_state.value, 1) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 6e49674a7b9..4de68057084 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -1,57 +1,148 @@ """Package to test the get_accessory method.""" -from unittest.mock import patch, MagicMock +from unittest.mock import patch, Mock + +import pytest from homeassistant.core import State -from homeassistant.components.homekit import ( - TYPES, get_accessory, import_types) +import homeassistant.components.cover as cover +import homeassistant.components.climate as climate +import homeassistant.components.media_player as media_player +from homeassistant.components.homekit import get_accessory, TYPES +from homeassistant.components.homekit.const import ( + CONF_FEATURE_LIST, FEATURE_ON_OFF, TYPE_OUTLET, TYPE_SWITCH) from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, ATTR_SUPPORTED_FEATURES, - TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_UNKNOWN) + ATTR_CODE, ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, TEMP_CELSIUS, + TEMP_FAHRENHEIT) -def test_import_types(): - """Test if all type files are imported correctly.""" - try: - import_types() - assert True - # pylint: disable=broad-except - except Exception: - assert False +def test_not_supported(caplog): + """Test if none is returned if entity isn't supported.""" + # not supported entity + assert get_accessory(None, None, State('demo.demo', 'on'), 2, {}) \ + is None + + # invalid aid + assert get_accessory(None, None, State('light.demo', 'on'), None, None) \ + is None + assert caplog.records[0].levelname == 'WARNING' + assert 'invalid aid' in caplog.records[0].msg -def test_component_not_supported(): - """Test with unsupported component.""" - state = State('demo.unsupported', STATE_UNKNOWN) +def test_not_supported_media_player(): + """Test if mode isn't supported and if no supported modes.""" + # selected mode for entity not supported + config = {CONF_FEATURE_LIST: {FEATURE_ON_OFF: None}} + entity_state = State('media_player.demo', 'on') + get_accessory(None, None, entity_state, 2, config) is None - assert True if get_accessory(None, state) is None else False + # no supported modes for entity + entity_state = State('media_player.demo', 'on') + assert get_accessory(None, None, entity_state, 2, {}) is None -def test_sensor_temperature_celsius(): - """Test temperature sensor with Celsius as unit.""" - mock_type = MagicMock() - with patch.dict(TYPES, {'TemperatureSensor': mock_type}): - state = State('sensor.temperature', '23', - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - get_accessory(None, state) - assert len(mock_type.mock_calls) == 1 +@pytest.mark.parametrize('config, name', [ + ({CONF_NAME: 'Customize Name'}, 'Customize Name'), +]) +def test_customize_options(config, name): + """Test with customized options.""" + mock_type = Mock() + with patch.dict(TYPES, {'Light': mock_type}): + entity_state = State('light.demo', 'on') + get_accessory(None, None, entity_state, 2, config) + mock_type.assert_called_with(None, None, name, + 'light.demo', 2, config) -# pylint: disable=invalid-name -def test_sensor_temperature_fahrenheit(): - """Test temperature sensor with Fahrenheit as unit.""" - mock_type = MagicMock() - with patch.dict(TYPES, {'TemperatureSensor': mock_type}): - state = State('sensor.temperature', '74', - {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) - get_accessory(None, state) - assert len(mock_type.mock_calls) == 1 +@pytest.mark.parametrize('type_name, entity_id, state, attrs, config', [ + ('Fan', 'fan.test', 'on', {}, {}), + ('Light', 'light.test', 'on', {}, {}), + ('Lock', 'lock.test', 'locked', {}, {ATTR_CODE: '1234'}), + ('MediaPlayer', 'media_player.test', 'on', + {ATTR_SUPPORTED_FEATURES: media_player.SUPPORT_TURN_ON | + media_player.SUPPORT_TURN_OFF}, {CONF_FEATURE_LIST: + {FEATURE_ON_OFF: None}}), + ('SecuritySystem', 'alarm_control_panel.test', 'armed', {}, + {ATTR_CODE: '1234'}), + ('Thermostat', 'climate.test', 'auto', {}, {}), + ('Thermostat', 'climate.test', 'auto', + {ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE_LOW | + climate.SUPPORT_TARGET_TEMPERATURE_HIGH}, {}), +]) +def test_types(type_name, entity_id, state, attrs, config): + """Test if types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, config) + assert mock_type.called + + if config: + assert mock_type.call_args[0][-1] == config -def test_cover_set_position(): - """Test cover with support for set_cover_position.""" - mock_type = MagicMock() - with patch.dict(TYPES, {'Window': mock_type}): - state = State('cover.set_position', 'open', - {ATTR_SUPPORTED_FEATURES: 4}) - get_accessory(None, state) - assert len(mock_type.mock_calls) == 1 +@pytest.mark.parametrize('type_name, entity_id, state, attrs', [ + ('GarageDoorOpener', 'cover.garage_door', 'open', + {ATTR_DEVICE_CLASS: 'garage', + ATTR_SUPPORTED_FEATURES: cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE}), + ('WindowCovering', 'cover.set_position', 'open', + {ATTR_SUPPORTED_FEATURES: 4}), + ('WindowCoveringBasic', 'cover.open_window', 'open', + {ATTR_SUPPORTED_FEATURES: 3}), +]) +def test_type_covers(type_name, entity_id, state, attrs): + """Test if cover types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, {}) + assert mock_type.called + + +@pytest.mark.parametrize('type_name, entity_id, state, attrs', [ + ('BinarySensor', 'binary_sensor.opening', 'on', + {ATTR_DEVICE_CLASS: 'opening'}), + ('BinarySensor', 'device_tracker.someone', 'not_home', {}), + ('AirQualitySensor', 'sensor.air_quality_pm25', '40', {}), + ('AirQualitySensor', 'sensor.air_quality', '40', + {ATTR_DEVICE_CLASS: 'pm25'}), + ('CarbonDioxideSensor', 'sensor.airmeter_co2', '500', {}), + ('CarbonDioxideSensor', 'sensor.airmeter', '500', + {ATTR_DEVICE_CLASS: 'co2'}), + ('HumiditySensor', 'sensor.humidity', '20', + {ATTR_DEVICE_CLASS: 'humidity', ATTR_UNIT_OF_MEASUREMENT: '%'}), + ('LightSensor', 'sensor.light', '900', {ATTR_DEVICE_CLASS: 'illuminance'}), + ('LightSensor', 'sensor.light', '900', {ATTR_UNIT_OF_MEASUREMENT: 'lm'}), + ('LightSensor', 'sensor.light', '900', {ATTR_UNIT_OF_MEASUREMENT: 'lx'}), + ('TemperatureSensor', 'sensor.temperature', '23', + {ATTR_DEVICE_CLASS: 'temperature'}), + ('TemperatureSensor', 'sensor.temperature', '23', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}), + ('TemperatureSensor', 'sensor.temperature', '74', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}), +]) +def test_type_sensors(type_name, entity_id, state, attrs): + """Test if sensor types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, {}) + assert mock_type.called + + +@pytest.mark.parametrize('type_name, entity_id, state, attrs, config', [ + ('Outlet', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_OUTLET}), + ('Switch', 'automation.test', 'on', {}, {}), + ('Switch', 'input_boolean.test', 'on', {}, {}), + ('Switch', 'remote.test', 'on', {}, {}), + ('Switch', 'script.test', 'on', {}, {}), + ('Switch', 'switch.test', 'on', {}, {}), + ('Switch', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_SWITCH}), +]) +def test_type_switches(type_name, entity_id, state, attrs, config): + """Test if switch types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, config) + assert mock_type.called diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 58c197e69ec..08e8da7857e 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -1,115 +1,216 @@ """Tests for the HomeKit component.""" +from unittest.mock import patch, ANY, Mock -import unittest -from unittest.mock import call, patch, ANY - -import voluptuous as vol - -# pylint: disable=unused-import -from pyhap.accessory_driver import AccessoryDriver # noqa F401 +import pytest from homeassistant import setup -from homeassistant.core import Event +from homeassistant.core import State from homeassistant.components.homekit import ( - CONF_PIN_CODE, HOMEKIT_FILE, HomeKit, valid_pin) + generate_aid, HomeKit, STATUS_READY, STATUS_RUNNING, + STATUS_STOPPED, STATUS_WAIT) +from homeassistant.components.homekit.accessories import HomeBridge +from homeassistant.components.homekit.const import ( + CONF_AUTO_START, DEFAULT_PORT, DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START) +from homeassistant.helpers.entityfilter import generate_filter from homeassistant.const import ( - CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + CONF_IP_ADDRESS, CONF_PORT, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) -from tests.common import get_test_home_assistant -from tests.mock.homekit import get_patch_paths, PATH_HOMEKIT +from tests.components.homekit.common import patch_debounce -PATH_ACC, _ = get_patch_paths() IP_ADDRESS = '127.0.0.1' - -CONFIG_MIN = {'homekit': {}} -CONFIG = { - 'homekit': { - CONF_PORT: 11111, - CONF_PIN_CODE: '987-65-432', - } -} +PATH_HOMEKIT = 'homeassistant.components.homekit' -class TestHomeKit(unittest.TestCase): - """Test setup of HomeKit component and HomeKit class.""" +@pytest.fixture(scope='module') +def debounce_patcher(): + """Patch debounce method.""" + patcher = patch_debounce() + yield patcher.start() + patcher.stop() - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() +def test_generate_aid(): + """Test generate aid method.""" + aid = generate_aid('demo.entity') + assert isinstance(aid, int) + assert aid >= 2 and aid <= 18446744073709551615 - def test_validate_pincode(self): - """Test async_setup with invalid config option.""" - schema = vol.Schema(valid_pin) + with patch(PATH_HOMEKIT + '.adler32') as mock_adler32: + mock_adler32.side_effect = [0, 1] + assert generate_aid('demo.entity') is None - for value in ('', '123-456-78', 'a23-45-678', '12345678', 1234): - with self.assertRaises(vol.MultipleInvalid): - schema(value) - for value in ('123-45-678', '234-56-789'): - self.assertTrue(schema(value)) +async def test_setup_min(hass): + """Test async_setup with min config options.""" + with patch(PATH_HOMEKIT + '.HomeKit') as mock_homekit: + assert await setup.async_setup_component( + hass, DOMAIN, {DOMAIN: {}}) - @patch(PATH_HOMEKIT + '.HomeKit') - def test_setup_min(self, mock_homekit): - """Test async_setup with minimal config option.""" - self.assertTrue(setup.setup_component( - self.hass, 'homekit', CONFIG_MIN)) + mock_homekit.assert_any_call(hass, DEFAULT_PORT, None, ANY, {}) + assert mock_homekit().setup.called is True - self.assertEqual(mock_homekit.mock_calls, - [call(self.hass, 51826), - call().setup_bridge(b'123-45-678')]) - mock_homekit.reset_mock() + # Test auto start enabled + mock_homekit.reset_mock() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() - self.hass.bus.fire(EVENT_HOMEASSISTANT_START) - self.hass.block_till_done() + mock_homekit().start.assert_called_with(ANY) - self.assertEqual(mock_homekit.mock_calls, - [call().start_driver(ANY)]) - @patch(PATH_HOMEKIT + '.HomeKit') - def test_setup_parameters(self, mock_homekit): - """Test async_setup with full config option.""" - self.assertTrue(setup.setup_component( - self.hass, 'homekit', CONFIG)) +async def test_setup_auto_start_disabled(hass): + """Test async_setup with auto start disabled and test service calls.""" + config = {DOMAIN: {CONF_AUTO_START: False, CONF_PORT: 11111, + CONF_IP_ADDRESS: '172.0.0.0'}} - self.assertEqual(mock_homekit.mock_calls, - [call(self.hass, 11111), - call().setup_bridge(b'987-65-432')]) + with patch(PATH_HOMEKIT + '.HomeKit') as mock_homekit: + mock_homekit.return_value = homekit = Mock() + assert await setup.async_setup_component( + hass, DOMAIN, config) - @patch('pyhap.accessory_driver.AccessoryDriver') - def test_homekit_class(self, mock_acc_driver): - """Test interaction between the HomeKit class and pyhap.""" - with patch(PATH_HOMEKIT + '.accessories.HomeBridge') as mock_bridge: - homekit = HomeKit(self.hass, 51826) - homekit.setup_bridge(b'123-45-678') + mock_homekit.assert_any_call(hass, 11111, '172.0.0.0', ANY, {}) + assert mock_homekit().setup.called is True - mock_bridge.reset_mock() - self.hass.states.set('demo.demo1', 'on') - self.hass.states.set('demo.demo2', 'off') + # Test auto_start disabled + homekit.reset_mock() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert homekit.start.called is False - with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc, \ - patch(PATH_HOMEKIT + '.import_types') as mock_import_types, \ - patch('homeassistant.util.get_local_ip') as mock_ip: - mock_get_acc.side_effect = ['TempSensor', 'Window'] - mock_ip.return_value = IP_ADDRESS - homekit.start_driver(Event(EVENT_HOMEASSISTANT_START)) + # Test start call with driver is ready + homekit.reset_mock() + homekit.status = STATUS_READY - path = self.hass.config.path(HOMEKIT_FILE) + await hass.services.async_call( + DOMAIN, SERVICE_HOMEKIT_START, blocking=True) + assert homekit.start.called is True - self.assertEqual(mock_import_types.call_count, 1) - self.assertEqual(mock_get_acc.call_count, 2) - self.assertEqual(mock_bridge.mock_calls, - [call().add_accessory('TempSensor'), - call().add_accessory('Window')]) - self.assertEqual(mock_acc_driver.mock_calls, - [call(homekit.bridge, 51826, IP_ADDRESS, path), - call().start()]) - mock_acc_driver.reset_mock() + # Test start call with driver started + homekit.reset_mock() + homekit.status = STATUS_STOPPED - self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP) - self.hass.block_till_done() + await hass.services.async_call( + DOMAIN, SERVICE_HOMEKIT_START, blocking=True) + assert homekit.start.called is False - self.assertEqual(mock_acc_driver.mock_calls, [call().stop()]) + +async def test_homekit_setup(hass, hk_driver): + """Test setup of bridge and driver.""" + homekit = HomeKit(hass, DEFAULT_PORT, None, {}, {}) + + with patch(PATH_HOMEKIT + '.accessories.HomeDriver', + return_value=hk_driver) as mock_driver, \ + patch('homeassistant.util.get_local_ip') as mock_ip: + mock_ip.return_value = IP_ADDRESS + await hass.async_add_job(homekit.setup) + + path = hass.config.path(HOMEKIT_FILE) + assert isinstance(homekit.bridge, HomeBridge) + mock_driver.assert_called_with( + hass, address=IP_ADDRESS, port=DEFAULT_PORT, persist_file=path) + + # Test if stop listener is setup + assert hass.bus.async_listeners().get(EVENT_HOMEASSISTANT_STOP) == 1 + + +async def test_homekit_setup_ip_address(hass, hk_driver): + """Test setup with given IP address.""" + homekit = HomeKit(hass, DEFAULT_PORT, '172.0.0.0', {}, {}) + + with patch(PATH_HOMEKIT + '.accessories.HomeDriver', + return_value=hk_driver) as mock_driver: + await hass.async_add_job(homekit.setup) + mock_driver.assert_called_with( + hass, address='172.0.0.0', port=DEFAULT_PORT, persist_file=ANY) + + +async def test_homekit_add_accessory(): + """Add accessory if config exists and get_acc returns an accessory.""" + homekit = HomeKit('hass', None, None, lambda entity_id: True, {}) + homekit.driver = 'driver' + homekit.bridge = mock_bridge = Mock() + + with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc: + + mock_get_acc.side_effect = [None, 'acc', None] + homekit.add_bridge_accessory(State('light.demo', 'on')) + mock_get_acc.assert_called_with('hass', 'driver', ANY, 363398124, {}) + assert not mock_bridge.add_accessory.called + + homekit.add_bridge_accessory(State('demo.test', 'on')) + mock_get_acc.assert_called_with('hass', 'driver', ANY, 294192020, {}) + assert mock_bridge.add_accessory.called + + homekit.add_bridge_accessory(State('demo.test_2', 'on')) + mock_get_acc.assert_called_with('hass', 'driver', ANY, 429982757, {}) + mock_bridge.add_accessory.assert_called_with('acc') + + +async def test_homekit_entity_filter(hass): + """Test the entity filter.""" + entity_filter = generate_filter(['cover'], ['demo.test'], [], []) + homekit = HomeKit(hass, None, None, entity_filter, {}) + + with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc: + mock_get_acc.return_value = None + + homekit.add_bridge_accessory(State('cover.test', 'open')) + assert mock_get_acc.called is True + mock_get_acc.reset_mock() + + homekit.add_bridge_accessory(State('demo.test', 'on')) + assert mock_get_acc.called is True + mock_get_acc.reset_mock() + + homekit.add_bridge_accessory(State('light.demo', 'light')) + assert mock_get_acc.called is False + + +async def test_homekit_start(hass, hk_driver, debounce_patcher): + """Test HomeKit start method.""" + pin = b'123-45-678' + homekit = HomeKit(hass, None, None, {}, {'cover.demo': {}}) + homekit.bridge = 'bridge' + homekit.driver = hk_driver + + hass.states.async_set('light.demo', 'on') + state = hass.states.async_all()[0] + + with patch(PATH_HOMEKIT + '.HomeKit.add_bridge_accessory') as \ + mock_add_acc, \ + patch(PATH_HOMEKIT + '.show_setup_message') as mock_setup_msg, \ + patch('pyhap.accessory_driver.AccessoryDriver.add_accessory') as \ + hk_driver_add_acc, \ + patch('pyhap.accessory_driver.AccessoryDriver.start') as \ + hk_driver_start: + await hass.async_add_job(homekit.start) + + mock_add_acc.assert_called_with(state) + mock_setup_msg.assert_called_with(hass, pin) + hk_driver_add_acc.assert_called_with('bridge') + assert hk_driver_start.called + assert homekit.status == STATUS_RUNNING + + # Test start() if already started + hk_driver_start.reset_mock() + await hass.async_add_job(homekit.start) + assert not hk_driver_start.called + + +async def test_homekit_stop(hass): + """Test HomeKit stop method.""" + homekit = HomeKit(hass, None, None, None, None) + homekit.driver = Mock() + + assert homekit.status == STATUS_READY + await hass.async_add_job(homekit.stop) + homekit.status = STATUS_WAIT + await hass.async_add_job(homekit.stop) + homekit.status = STATUS_STOPPED + await hass.async_add_job(homekit.stop) + assert homekit.driver.stop.called is False + + # Test if driver is started + homekit.status = STATUS_RUNNING + await hass.async_add_job(homekit.stop) + assert homekit.driver.stop.called is True diff --git a/tests/components/homekit/test_security_systems.py b/tests/components/homekit/test_security_systems.py deleted file mode 100644 index 4753e86c084..00000000000 --- a/tests/components/homekit/test_security_systems.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Test different accessory types: Security Systems.""" -import unittest -from unittest.mock import patch - -from homeassistant.core import callback -from homeassistant.components.homekit.security_systems import SecuritySystem -from homeassistant.const import ( - ATTR_SERVICE, EVENT_CALL_SERVICE, - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED) - -from tests.common import get_test_home_assistant -from tests.mock.homekit import get_patch_paths, mock_preload_service - -PATH_ACC, PATH_FILE = get_patch_paths('security_systems') - - -class TestHomekitSecuritySystems(unittest.TestCase): - """Test class for all accessory types regarding security systems.""" - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.events = [] - - @callback - def record_event(event): - """Track called event.""" - self.events.append(event) - - self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - def test_switch_set_state(self): - """Test if accessory and HA are updated accordingly.""" - acp = 'alarm_control_panel.testsecurity' - - with patch(PATH_ACC, side_effect=mock_preload_service): - with patch(PATH_FILE, side_effect=mock_preload_service): - acc = SecuritySystem(self.hass, acp, 'SecuritySystem') - acc.run() - - self.assertEqual(acc.char_current_state.value, 3) - self.assertEqual(acc.char_target_state.value, 3) - - self.hass.states.set(acp, STATE_ALARM_ARMED_AWAY) - self.hass.block_till_done() - self.assertEqual(acc.char_target_state.value, 1) - self.assertEqual(acc.char_current_state.value, 1) - - self.hass.states.set(acp, STATE_ALARM_ARMED_HOME) - self.hass.block_till_done() - self.assertEqual(acc.char_target_state.value, 0) - self.assertEqual(acc.char_current_state.value, 0) - - self.hass.states.set(acp, STATE_ALARM_ARMED_NIGHT) - self.hass.block_till_done() - self.assertEqual(acc.char_target_state.value, 2) - self.assertEqual(acc.char_current_state.value, 2) - - self.hass.states.set(acp, STATE_ALARM_DISARMED) - self.hass.block_till_done() - self.assertEqual(acc.char_target_state.value, 3) - self.assertEqual(acc.char_current_state.value, 3) - - # Set from HomeKit - acc.char_target_state.set_value(0) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'alarm_arm_home') - self.assertEqual(acc.char_target_state.value, 0) - - acc.char_target_state.set_value(1) - self.hass.block_till_done() - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'alarm_arm_away') - self.assertEqual(acc.char_target_state.value, 1) - - acc.char_target_state.set_value(2) - self.hass.block_till_done() - self.assertEqual( - self.events[2].data[ATTR_SERVICE], 'alarm_arm_night') - self.assertEqual(acc.char_target_state.value, 2) - - acc.char_target_state.set_value(3) - self.hass.block_till_done() - self.assertEqual( - self.events[3].data[ATTR_SERVICE], 'alarm_disarm') - self.assertEqual(acc.char_target_state.value, 3) diff --git a/tests/components/homekit/test_sensors.py b/tests/components/homekit/test_sensors.py deleted file mode 100644 index 4698c363503..00000000000 --- a/tests/components/homekit/test_sensors.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Test different accessory types: Sensors.""" -import unittest -from unittest.mock import patch - -from homeassistant.components.homekit.const import PROP_CELSIUS -from homeassistant.components.homekit.sensors import ( - TemperatureSensor, calc_temperature) -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_UNKNOWN) - -from tests.common import get_test_home_assistant -from tests.mock.homekit import get_patch_paths, mock_preload_service - -PATH_ACC, PATH_FILE = get_patch_paths('sensors') - - -def test_calc_temperature(): - """Test if temperature in Celsius is calculated correctly.""" - assert calc_temperature(STATE_UNKNOWN) is None - assert calc_temperature('test') is None - - assert calc_temperature('20') == 20 - assert calc_temperature('20.12', TEMP_CELSIUS) == 20.12 - - assert calc_temperature('75.2', TEMP_FAHRENHEIT) == 24 - assert calc_temperature('-20.6', TEMP_FAHRENHEIT) == -29.22 - - -class TestHomekitSensors(unittest.TestCase): - """Test class for all accessory types regarding sensors.""" - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - get_patch_paths('sensors') - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - def test_temperature(self): - """Test if accessory is updated after state change.""" - temperature_sensor = 'sensor.temperature' - - with patch(PATH_ACC, side_effect=mock_preload_service): - with patch(PATH_FILE, side_effect=mock_preload_service): - acc = TemperatureSensor(self.hass, temperature_sensor, - 'Temperature') - acc.run() - - self.assertEqual(acc.char_temp.value, 0.0) - self.assertEqual(acc.char_temp.properties, PROP_CELSIUS) - - self.hass.states.set(temperature_sensor, STATE_UNKNOWN, - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - - self.hass.states.set(temperature_sensor, '20', - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_temp.value, 20) - - self.hass.states.set(temperature_sensor, '75.2', - {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) - self.hass.block_till_done() - self.assertEqual(acc.char_temp.value, 24) diff --git a/tests/components/homekit/test_switches.py b/tests/components/homekit/test_switches.py deleted file mode 100644 index d9f2d6c1d1a..00000000000 --- a/tests/components/homekit/test_switches.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Test different accessory types: Switches.""" -import unittest -from unittest.mock import patch - -from homeassistant.core import callback -from homeassistant.components.homekit.switches import Switch -from homeassistant.const import ATTR_SERVICE, EVENT_CALL_SERVICE - -from tests.common import get_test_home_assistant -from tests.mock.homekit import get_patch_paths, mock_preload_service - -PATH_ACC, PATH_FILE = get_patch_paths('switches') - - -class TestHomekitSwitches(unittest.TestCase): - """Test class for all accessory types regarding switches.""" - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.events = [] - - @callback - def record_event(event): - """Track called event.""" - self.events.append(event) - - self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - def test_switch_set_state(self): - """Test if accessory and HA are updated accordingly.""" - switch = 'switch.testswitch' - - with patch(PATH_ACC, side_effect=mock_preload_service): - with patch(PATH_FILE, side_effect=mock_preload_service): - acc = Switch(self.hass, switch, 'Switch') - acc.run() - - self.assertEqual(acc.char_on.value, False) - - self.hass.states.set(switch, 'on') - self.hass.block_till_done() - self.assertEqual(acc.char_on.value, True) - - self.hass.states.set(switch, 'off') - self.hass.block_till_done() - self.assertEqual(acc.char_on.value, False) - - # Set from HomeKit - acc.char_on.set_value(True) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'turn_on') - self.assertEqual(acc.char_on.value, True) - - acc.char_on.set_value(False) - self.hass.block_till_done() - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'turn_off') - self.assertEqual(acc.char_on.value, False) diff --git a/tests/components/homekit/test_thermostats.py b/tests/components/homekit/test_thermostats.py deleted file mode 100644 index fabffe881bb..00000000000 --- a/tests/components/homekit/test_thermostats.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Test different accessory types: Thermostats.""" -import unittest -from unittest.mock import patch - -from homeassistant.core import callback -from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, ATTR_TEMPERATURE, - ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH, - ATTR_OPERATION_MODE, STATE_HEAT, STATE_AUTO) -from homeassistant.components.homekit.thermostats import Thermostat, STATE_OFF -from homeassistant.const import ( - ATTR_SERVICE, EVENT_CALL_SERVICE, ATTR_SERVICE_DATA, - ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) - -from tests.common import get_test_home_assistant -from tests.mock.homekit import get_patch_paths, mock_preload_service - -PATH_ACC, PATH_FILE = get_patch_paths('thermostats') - - -class TestHomekitThermostats(unittest.TestCase): - """Test class for all accessory types regarding thermostats.""" - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.events = [] - - @callback - def record_event(event): - """Track called event.""" - self.events.append(event) - - self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - def test_default_thermostat(self): - """Test if accessory and HA are updated accordingly.""" - climate = 'climate.testclimate' - - with patch(PATH_ACC, side_effect=mock_preload_service): - with patch(PATH_FILE, side_effect=mock_preload_service): - acc = Thermostat(self.hass, climate, 'Climate', False) - acc.run() - - self.assertEqual(acc.char_current_heat_cool.value, 0) - self.assertEqual(acc.char_target_heat_cool.value, 0) - self.assertEqual(acc.char_current_temp.value, 21.0) - self.assertEqual(acc.char_target_temp.value, 21.0) - self.assertEqual(acc.char_display_units.value, 0) - self.assertEqual(acc.char_cooling_thresh_temp, None) - self.assertEqual(acc.char_heating_thresh_temp, None) - - self.hass.states.set(climate, STATE_HEAT, - {ATTR_OPERATION_MODE: STATE_HEAT, - ATTR_TEMPERATURE: 22.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_target_temp.value, 22.0) - self.assertEqual(acc.char_current_heat_cool.value, 1) - self.assertEqual(acc.char_target_heat_cool.value, 1) - self.assertEqual(acc.char_current_temp.value, 18.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_HEAT, - {ATTR_OPERATION_MODE: STATE_HEAT, - ATTR_TEMPERATURE: 22.0, - ATTR_CURRENT_TEMPERATURE: 23.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_target_temp.value, 22.0) - self.assertEqual(acc.char_current_heat_cool.value, 0) - self.assertEqual(acc.char_target_heat_cool.value, 1) - self.assertEqual(acc.char_current_temp.value, 23.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_OFF, - {ATTR_OPERATION_MODE: STATE_OFF, - ATTR_TEMPERATURE: 22.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_target_temp.value, 22.0) - self.assertEqual(acc.char_current_heat_cool.value, 0) - self.assertEqual(acc.char_target_heat_cool.value, 0) - self.assertEqual(acc.char_current_temp.value, 18.0) - self.assertEqual(acc.char_display_units.value, 0) - - # Set from HomeKit - acc.char_target_temp.set_value(19.0) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'set_temperature') - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA][ATTR_TEMPERATURE], 19.0) - self.assertEqual(acc.char_target_temp.value, 19.0) - - acc.char_target_heat_cool.set_value(1) - self.hass.block_till_done() - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'set_operation_mode') - self.assertEqual( - self.events[1].data[ATTR_SERVICE_DATA][ATTR_OPERATION_MODE], - STATE_HEAT) - self.assertEqual(acc.char_target_heat_cool.value, 1) - - def test_auto_thermostat(self): - """Test if accessory and HA are updated accordingly.""" - climate = 'climate.testclimate' - - acc = Thermostat(self.hass, climate, 'Climate', True) - acc.run() - - self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) - self.assertEqual(acc.char_heating_thresh_temp.value, 19.0) - - self.hass.states.set(climate, STATE_AUTO, - {ATTR_OPERATION_MODE: STATE_AUTO, - ATTR_TARGET_TEMP_HIGH: 22.0, - ATTR_TARGET_TEMP_LOW: 20.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_heating_thresh_temp.value, 20.0) - self.assertEqual(acc.char_cooling_thresh_temp.value, 22.0) - self.assertEqual(acc.char_current_heat_cool.value, 1) - self.assertEqual(acc.char_target_heat_cool.value, 3) - self.assertEqual(acc.char_current_temp.value, 18.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_AUTO, - {ATTR_OPERATION_MODE: STATE_AUTO, - ATTR_TARGET_TEMP_HIGH: 23.0, - ATTR_TARGET_TEMP_LOW: 19.0, - ATTR_CURRENT_TEMPERATURE: 24.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_heating_thresh_temp.value, 19.0) - self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) - self.assertEqual(acc.char_current_heat_cool.value, 2) - self.assertEqual(acc.char_target_heat_cool.value, 3) - self.assertEqual(acc.char_current_temp.value, 24.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_AUTO, - {ATTR_OPERATION_MODE: STATE_AUTO, - ATTR_TARGET_TEMP_HIGH: 23.0, - ATTR_TARGET_TEMP_LOW: 19.0, - ATTR_CURRENT_TEMPERATURE: 21.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_heating_thresh_temp.value, 19.0) - self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) - self.assertEqual(acc.char_current_heat_cool.value, 0) - self.assertEqual(acc.char_target_heat_cool.value, 3) - self.assertEqual(acc.char_current_temp.value, 21.0) - self.assertEqual(acc.char_display_units.value, 0) - - # Set from HomeKit - acc.char_heating_thresh_temp.set_value(20.0) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'set_temperature') - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA][ATTR_TARGET_TEMP_LOW], 20.0) - self.assertEqual(acc.char_heating_thresh_temp.value, 20.0) - - acc.char_cooling_thresh_temp.set_value(25.0) - self.hass.block_till_done() - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'set_temperature') - self.assertEqual( - self.events[1].data[ATTR_SERVICE_DATA][ATTR_TARGET_TEMP_HIGH], - 25.0) - self.assertEqual(acc.char_cooling_thresh_temp.value, 25.0) diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py new file mode 100644 index 00000000000..c69ddacd328 --- /dev/null +++ b/tests/components/homekit/test_type_covers.py @@ -0,0 +1,235 @@ +"""Test different accessory types: Covers.""" +from collections import namedtuple + +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN) + +from tests.common import async_mock_service +from tests.components.homekit.common import patch_debounce + + +@pytest.fixture(scope='module') +def cls(): + """Patch debounce decorator during import of type_covers.""" + patcher = patch_debounce() + patcher.start() + _import = __import__('homeassistant.components.homekit.type_covers', + fromlist=['GarageDoorOpener', 'WindowCovering,', + 'WindowCoveringBasic']) + patcher_tuple = namedtuple('Cls', ['window', 'window_basic', 'garage']) + yield patcher_tuple(window=_import.WindowCovering, + window_basic=_import.WindowCoveringBasic, + garage=_import.GarageDoorOpener) + patcher.stop() + + +async def test_garage_door_open_close(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'cover.garage_door' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = cls.garage(hass, hk_driver, 'Garage Door', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 4 # GarageDoorOpener + + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + + hass.states.async_set(entity_id, STATE_CLOSED) + await hass.async_block_till_done() + assert acc.char_current_state.value == 1 + assert acc.char_target_state.value == 1 + + hass.states.async_set(entity_id, STATE_OPEN) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + + hass.states.async_set(entity_id, STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + + # Set from HomeKit + call_close_cover = async_mock_service(hass, DOMAIN, 'close_cover') + call_open_cover = async_mock_service(hass, DOMAIN, 'open_cover') + + await hass.async_add_job(acc.char_target_state.client_update_value, 1) + await hass.async_block_till_done() + assert call_close_cover + assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_state.value == 2 + assert acc.char_target_state.value == 1 + + hass.states.async_set(entity_id, STATE_CLOSED) + await hass.async_block_till_done() + + await hass.async_add_job(acc.char_target_state.client_update_value, 0) + await hass.async_block_till_done() + assert call_open_cover + assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_state.value == 3 + assert acc.char_target_state.value == 0 + + +async def test_window_set_cover_position(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'cover.window' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = cls.window(hass, hk_driver, 'Cover', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 14 # WindowCovering + + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN, + {ATTR_CURRENT_POSITION: None}) + await hass.async_block_till_done() + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + + hass.states.async_set(entity_id, STATE_OPEN, + {ATTR_CURRENT_POSITION: 50}) + await hass.async_block_till_done() + assert acc.char_current_position.value == 50 + assert acc.char_target_position.value == 50 + + # Set from HomeKit + call_set_cover_position = async_mock_service(hass, DOMAIN, + 'set_cover_position') + + await hass.async_add_job(acc.char_target_position.client_update_value, 25) + await hass.async_block_till_done() + assert call_set_cover_position[0] + assert call_set_cover_position[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_cover_position[0].data[ATTR_POSITION] == 25 + assert acc.char_current_position.value == 50 + assert acc.char_target_position.value == 25 + + await hass.async_add_job(acc.char_target_position.client_update_value, 75) + await hass.async_block_till_done() + assert call_set_cover_position[1] + assert call_set_cover_position[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_cover_position[1].data[ATTR_POSITION] == 75 + assert acc.char_current_position.value == 50 + assert acc.char_target_position.value == 75 + + +async def test_window_open_close(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'cover.window' + + hass.states.async_set(entity_id, STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: 0}) + acc = cls.window_basic(hass, hk_driver, 'Cover', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 14 # WindowCovering + + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + assert acc.char_position_state.value == 2 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + assert acc.char_position_state.value == 2 + + hass.states.async_set(entity_id, STATE_OPEN) + await hass.async_block_till_done() + assert acc.char_current_position.value == 100 + assert acc.char_target_position.value == 100 + assert acc.char_position_state.value == 2 + + hass.states.async_set(entity_id, STATE_CLOSED) + await hass.async_block_till_done() + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + assert acc.char_position_state.value == 2 + + # Set from HomeKit + call_close_cover = async_mock_service(hass, DOMAIN, 'close_cover') + call_open_cover = async_mock_service(hass, DOMAIN, 'open_cover') + + await hass.async_add_job(acc.char_target_position.client_update_value, 25) + await hass.async_block_till_done() + assert call_close_cover + assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + assert acc.char_position_state.value == 2 + + await hass.async_add_job(acc.char_target_position.client_update_value, 90) + await hass.async_block_till_done() + assert call_open_cover[0] + assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_position.value == 100 + assert acc.char_target_position.value == 100 + assert acc.char_position_state.value == 2 + + await hass.async_add_job(acc.char_target_position.client_update_value, 55) + await hass.async_block_till_done() + assert call_open_cover[1] + assert call_open_cover[1].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_position.value == 100 + assert acc.char_target_position.value == 100 + assert acc.char_position_state.value == 2 + + +async def test_window_open_close_stop(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'cover.window' + + hass.states.async_set(entity_id, STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: SUPPORT_STOP}) + acc = cls.window_basic(hass, hk_driver, 'Cover', entity_id, 2, None) + await hass.async_add_job(acc.run) + + # Set from HomeKit + call_close_cover = async_mock_service(hass, DOMAIN, 'close_cover') + call_open_cover = async_mock_service(hass, DOMAIN, 'open_cover') + call_stop_cover = async_mock_service(hass, DOMAIN, 'stop_cover') + + await hass.async_add_job(acc.char_target_position.client_update_value, 25) + await hass.async_block_till_done() + assert call_close_cover + assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + assert acc.char_position_state.value == 2 + + await hass.async_add_job(acc.char_target_position.client_update_value, 90) + await hass.async_block_till_done() + assert call_open_cover + assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_position.value == 100 + assert acc.char_target_position.value == 100 + assert acc.char_position_state.value == 2 + + await hass.async_add_job(acc.char_target_position.client_update_value, 55) + await hass.async_block_till_done() + assert call_stop_cover + assert call_stop_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_position.value == 50 + assert acc.char_target_position.value == 50 + assert acc.char_position_state.value == 2 diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py new file mode 100644 index 00000000000..ba7d4ccdcf0 --- /dev/null +++ b/tests/components/homekit/test_type_fans.py @@ -0,0 +1,149 @@ +"""Test different accessory types: Fans.""" +from collections import namedtuple + +import pytest + +from homeassistant.components.fan import ( + ATTR_DIRECTION, ATTR_OSCILLATING, DIRECTION_FORWARD, DIRECTION_REVERSE, + DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, + SUPPORT_DIRECTION, SUPPORT_OSCILLATE) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_OFF, + STATE_UNKNOWN, SERVICE_TURN_ON, SERVICE_TURN_OFF) + +from tests.common import async_mock_service +from tests.components.homekit.common import patch_debounce + + +@pytest.fixture(scope='module') +def cls(): + """Patch debounce decorator during import of type_fans.""" + patcher = patch_debounce() + patcher.start() + _import = __import__('homeassistant.components.homekit.type_fans', + fromlist=['Fan']) + patcher_tuple = namedtuple('Cls', ['fan']) + yield patcher_tuple(fan=_import.Fan) + patcher.stop() + + +async def test_fan_basic(hass, hk_driver, cls): + """Test fan with char state.""" + entity_id = 'fan.demo' + + hass.states.async_set(entity_id, STATE_ON, + {ATTR_SUPPORTED_FEATURES: 0}) + await hass.async_block_till_done() + acc = cls.fan(hass, hk_driver, 'Fan', entity_id, 2, None) + + assert acc.aid == 2 + assert acc.category == 3 # Fan + assert acc.char_active.value == 0 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_active.value == 1 + + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_SUPPORTED_FEATURES: 0}) + await hass.async_block_till_done() + assert acc.char_active.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_active.value == 0 + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert acc.char_active.value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + call_turn_off = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF) + + await hass.async_add_job(acc.char_active.client_update_value, 1) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + + hass.states.async_set(entity_id, STATE_ON) + await hass.async_block_till_done() + + await hass.async_add_job(acc.char_active.client_update_value, 0) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + + +async def test_fan_direction(hass, hk_driver, cls): + """Test fan with direction.""" + entity_id = 'fan.demo' + + hass.states.async_set(entity_id, STATE_ON, { + ATTR_SUPPORTED_FEATURES: SUPPORT_DIRECTION, + ATTR_DIRECTION: DIRECTION_FORWARD}) + await hass.async_block_till_done() + acc = cls.fan(hass, hk_driver, 'Fan', entity_id, 2, None) + + assert acc.char_direction.value == 0 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_direction.value == 0 + + hass.states.async_set(entity_id, STATE_ON, + {ATTR_DIRECTION: DIRECTION_REVERSE}) + await hass.async_block_till_done() + assert acc.char_direction.value == 1 + + # Set from HomeKit + call_set_direction = async_mock_service(hass, DOMAIN, + SERVICE_SET_DIRECTION) + + await hass.async_add_job(acc.char_direction.client_update_value, 0) + await hass.async_block_till_done() + assert call_set_direction[0] + assert call_set_direction[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_direction[0].data[ATTR_DIRECTION] == DIRECTION_FORWARD + + await hass.async_add_job(acc.char_direction.client_update_value, 1) + await hass.async_block_till_done() + assert call_set_direction[1] + assert call_set_direction[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_direction[1].data[ATTR_DIRECTION] == DIRECTION_REVERSE + + +async def test_fan_oscillate(hass, hk_driver, cls): + """Test fan with oscillate.""" + entity_id = 'fan.demo' + + hass.states.async_set(entity_id, STATE_ON, { + ATTR_SUPPORTED_FEATURES: SUPPORT_OSCILLATE, ATTR_OSCILLATING: False}) + await hass.async_block_till_done() + acc = cls.fan(hass, hk_driver, 'Fan', entity_id, 2, None) + + assert acc.char_swing.value == 0 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_swing.value == 0 + + hass.states.async_set(entity_id, STATE_ON, + {ATTR_OSCILLATING: True}) + await hass.async_block_till_done() + assert acc.char_swing.value == 1 + + # Set from HomeKit + call_oscillate = async_mock_service(hass, DOMAIN, SERVICE_OSCILLATE) + + await hass.async_add_job(acc.char_swing.client_update_value, 0) + await hass.async_block_till_done() + assert call_oscillate[0] + assert call_oscillate[0].data[ATTR_ENTITY_ID] == entity_id + assert call_oscillate[0].data[ATTR_OSCILLATING] is False + + await hass.async_add_job(acc.char_swing.client_update_value, 1) + await hass.async_block_till_done() + assert call_oscillate[1] + assert call_oscillate[1].data[ATTR_ENTITY_ID] == entity_id + assert call_oscillate[1].data[ATTR_OSCILLATING] is True diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py new file mode 100644 index 00000000000..a9a5f1c3ece --- /dev/null +++ b/tests/components/homekit/test_type_lights.py @@ -0,0 +1,174 @@ +"""Test different accessory types: Lights.""" +from collections import namedtuple + +import pytest + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + DOMAIN, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + STATE_ON, STATE_OFF, STATE_UNKNOWN) + +from tests.common import async_mock_service +from tests.components.homekit.common import patch_debounce + + +@pytest.fixture(scope='module') +def cls(): + """Patch debounce decorator during import of type_lights.""" + patcher = patch_debounce() + patcher.start() + _import = __import__('homeassistant.components.homekit.type_lights', + fromlist=['Light']) + patcher_tuple = namedtuple('Cls', ['light']) + yield patcher_tuple(light=_import.Light) + patcher.stop() + + +async def test_light_basic(hass, hk_driver, cls): + """Test light with char state.""" + entity_id = 'light.demo' + + hass.states.async_set(entity_id, STATE_ON, + {ATTR_SUPPORTED_FEATURES: 0}) + await hass.async_block_till_done() + acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None) + + assert acc.aid == 2 + assert acc.category == 5 # Lightbulb + assert acc.char_on.value == 0 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_on.value == 1 + + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_SUPPORTED_FEATURES: 0}) + await hass.async_block_till_done() + assert acc.char_on.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_on.value == 0 + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert acc.char_on.value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off') + + await hass.async_add_job(acc.char_on.client_update_value, 1) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + + hass.states.async_set(entity_id, STATE_ON) + await hass.async_block_till_done() + + await hass.async_add_job(acc.char_on.client_update_value, 0) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + + +async def test_light_brightness(hass, hk_driver, cls): + """Test light with brightness.""" + entity_id = 'light.demo' + + hass.states.async_set(entity_id, STATE_ON, { + ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS: 255}) + await hass.async_block_till_done() + acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None) + + assert acc.char_brightness.value == 0 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_brightness.value == 100 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + await hass.async_block_till_done() + assert acc.char_brightness.value == 40 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off') + + await hass.async_add_job(acc.char_brightness.client_update_value, 20) + await hass.async_add_job(acc.char_on.client_update_value, 1) + await hass.async_block_till_done() + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 + + await hass.async_add_job(acc.char_on.client_update_value, 1) + await hass.async_add_job(acc.char_brightness.client_update_value, 40) + await hass.async_block_till_done() + assert call_turn_on[1] + assert call_turn_on[1].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[1].data[ATTR_BRIGHTNESS_PCT] == 40 + + await hass.async_add_job(acc.char_on.client_update_value, 1) + await hass.async_add_job(acc.char_brightness.client_update_value, 0) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + + +async def test_light_color_temperature(hass, hk_driver, cls): + """Test light with color temperature.""" + entity_id = 'light.demo' + + hass.states.async_set(entity_id, STATE_ON, { + ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR_TEMP, + ATTR_COLOR_TEMP: 190}) + await hass.async_block_till_done() + acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None) + + assert acc.char_color_temperature.value == 153 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_color_temperature.value == 190 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + + await hass.async_add_job( + acc.char_color_temperature.client_update_value, 250) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 + + +async def test_light_rgb_color(hass, hk_driver, cls): + """Test light with rgb_color.""" + entity_id = 'light.demo' + + hass.states.async_set(entity_id, STATE_ON, { + ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR, + ATTR_HS_COLOR: (260, 90)}) + await hass.async_block_till_done() + acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None) + + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 75 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_hue.value == 260 + assert acc.char_saturation.value == 90 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + + await hass.async_add_job(acc.char_hue.client_update_value, 145) + await hass.async_add_job(acc.char_saturation.client_update_value, 75) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_HS_COLOR] == (145, 75) diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py new file mode 100644 index 00000000000..8f18a591019 --- /dev/null +++ b/tests/components/homekit/test_type_locks.py @@ -0,0 +1,85 @@ +"""Test different accessory types: Locks.""" +import pytest + +from homeassistant.components.homekit.type_locks import Lock +from homeassistant.components.lock import DOMAIN +from homeassistant.const import ( + ATTR_CODE, ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED) + +from tests.common import async_mock_service + + +async def test_lock_unlock(hass, hk_driver): + """Test if accessory and HA are updated accordingly.""" + code = '1234' + config = {ATTR_CODE: code} + entity_id = 'lock.kitchen_door' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Lock(hass, hk_driver, 'Lock', entity_id, 2, config) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 6 # DoorLock + + assert acc.char_current_state.value == 3 + assert acc.char_target_state.value == 1 + + hass.states.async_set(entity_id, STATE_LOCKED) + await hass.async_block_till_done() + assert acc.char_current_state.value == 1 + assert acc.char_target_state.value == 1 + + hass.states.async_set(entity_id, STATE_UNLOCKED) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_current_state.value == 3 + assert acc.char_target_state.value == 0 + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert acc.char_current_state.value == 3 + assert acc.char_target_state.value == 0 + + # Set from HomeKit + call_lock = async_mock_service(hass, DOMAIN, 'lock') + call_unlock = async_mock_service(hass, DOMAIN, 'unlock') + + await hass.async_add_job(acc.char_target_state.client_update_value, 1) + await hass.async_block_till_done() + assert call_lock + assert call_lock[0].data[ATTR_ENTITY_ID] == entity_id + assert call_lock[0].data[ATTR_CODE] == code + assert acc.char_target_state.value == 1 + + await hass.async_add_job(acc.char_target_state.client_update_value, 0) + await hass.async_block_till_done() + assert call_unlock + assert call_unlock[0].data[ATTR_ENTITY_ID] == entity_id + assert call_unlock[0].data[ATTR_CODE] == code + assert acc.char_target_state.value == 0 + + +@pytest.mark.parametrize('config', [{}, {ATTR_CODE: None}]) +async def test_no_code(hass, hk_driver, config): + """Test accessory if lock doesn't require a code.""" + entity_id = 'lock.kitchen_door' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Lock(hass, hk_driver, 'Lock', entity_id, 2, config) + + # Set from HomeKit + call_lock = async_mock_service(hass, DOMAIN, 'lock') + + await hass.async_add_job(acc.char_target_state.client_update_value, 1) + await hass.async_block_till_done() + assert call_lock + assert call_lock[0].data[ATTR_ENTITY_ID] == entity_id + assert ATTR_CODE not in call_lock[0].data + assert acc.char_target_state.value == 1 diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py new file mode 100644 index 00000000000..4076b1f8a89 --- /dev/null +++ b/tests/components/homekit/test_type_media_players.py @@ -0,0 +1,117 @@ +"""Test different accessory types: Media Players.""" + +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_MUTED, DOMAIN) +from homeassistant.components.homekit.type_media_players import MediaPlayer +from homeassistant.components.homekit.const import ( + CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, + FEATURE_TOGGLE_MUTE) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SERVICE_VOLUME_MUTE, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, + STATE_PLAYING) + +from tests.common import async_mock_service + + +async def test_media_player_set_state(hass, hk_driver): + """Test if accessory and HA are updated accordingly.""" + config = {CONF_FEATURE_LIST: { + FEATURE_ON_OFF: None, FEATURE_PLAY_PAUSE: None, + FEATURE_PLAY_STOP: None, FEATURE_TOGGLE_MUTE: None}} + entity_id = 'media_player.test' + + hass.states.async_set(entity_id, None, {ATTR_SUPPORTED_FEATURES: 20873, + ATTR_MEDIA_VOLUME_MUTED: False}) + await hass.async_block_till_done() + acc = MediaPlayer(hass, hk_driver, 'MediaPlayer', entity_id, 2, config) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 8 # Switch + + assert acc.chars[FEATURE_ON_OFF].value == 0 + assert acc.chars[FEATURE_PLAY_PAUSE].value == 0 + assert acc.chars[FEATURE_PLAY_STOP].value == 0 + assert acc.chars[FEATURE_TOGGLE_MUTE].value == 0 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) + await hass.async_block_till_done() + assert acc.chars[FEATURE_ON_OFF].value == 1 + assert acc.chars[FEATURE_TOGGLE_MUTE].value == 1 + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.chars[FEATURE_ON_OFF].value == 0 + + hass.states.async_set(entity_id, STATE_PLAYING) + await hass.async_block_till_done() + assert acc.chars[FEATURE_PLAY_PAUSE].value == 1 + assert acc.chars[FEATURE_PLAY_STOP].value == 1 + + hass.states.async_set(entity_id, STATE_PAUSED) + await hass.async_block_till_done() + assert acc.chars[FEATURE_PLAY_PAUSE].value == 0 + + hass.states.async_set(entity_id, STATE_IDLE) + await hass.async_block_till_done() + assert acc.chars[FEATURE_PLAY_STOP].value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + call_turn_off = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF) + call_media_play = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PLAY) + call_media_pause = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PAUSE) + call_media_stop = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_STOP) + call_toggle_mute = async_mock_service(hass, DOMAIN, SERVICE_VOLUME_MUTE) + + await hass.async_add_job(acc.chars[FEATURE_ON_OFF] + .client_update_value, True) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[FEATURE_ON_OFF] + .client_update_value, False) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[FEATURE_PLAY_PAUSE] + .client_update_value, True) + await hass.async_block_till_done() + assert call_media_play + assert call_media_play[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[FEATURE_PLAY_PAUSE] + .client_update_value, False) + await hass.async_block_till_done() + assert call_media_pause + assert call_media_pause[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[FEATURE_PLAY_STOP] + .client_update_value, True) + await hass.async_block_till_done() + assert call_media_play + assert call_media_play[1].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[FEATURE_PLAY_STOP] + .client_update_value, False) + await hass.async_block_till_done() + assert call_media_stop + assert call_media_stop[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[FEATURE_TOGGLE_MUTE] + .client_update_value, True) + await hass.async_block_till_done() + assert call_toggle_mute + assert call_toggle_mute[0].data[ATTR_ENTITY_ID] == entity_id + assert call_toggle_mute[0].data[ATTR_MEDIA_VOLUME_MUTED] is True + + await hass.async_add_job(acc.chars[FEATURE_TOGGLE_MUTE] + .client_update_value, False) + await hass.async_block_till_done() + assert call_toggle_mute + assert call_toggle_mute[1].data[ATTR_ENTITY_ID] == entity_id + assert call_toggle_mute[1].data[ATTR_MEDIA_VOLUME_MUTED] is False diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py new file mode 100644 index 00000000000..3ddce0f36eb --- /dev/null +++ b/tests/components/homekit/test_type_security_systems.py @@ -0,0 +1,116 @@ +"""Test different accessory types: Security Systems.""" +import pytest + +from homeassistant.components.alarm_control_panel import DOMAIN +from homeassistant.components.homekit.type_security_systems import \ + SecuritySystem +from homeassistant.const import ( + ATTR_CODE, ATTR_ENTITY_ID, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, + STATE_UNKNOWN) + +from tests.common import async_mock_service + + +async def test_switch_set_state(hass, hk_driver): + """Test if accessory and HA are updated accordingly.""" + code = '1234' + config = {ATTR_CODE: code} + entity_id = 'alarm_control_panel.test' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = SecuritySystem(hass, hk_driver, 'SecuritySystem', + entity_id, 2, config) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 11 # AlarmSystem + + assert acc.char_current_state.value == 3 + assert acc.char_target_state.value == 3 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_AWAY) + await hass.async_block_till_done() + assert acc.char_target_state.value == 1 + assert acc.char_current_state.value == 1 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_HOME) + await hass.async_block_till_done() + assert acc.char_target_state.value == 0 + assert acc.char_current_state.value == 0 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_NIGHT) + await hass.async_block_till_done() + assert acc.char_target_state.value == 2 + assert acc.char_current_state.value == 2 + + hass.states.async_set(entity_id, STATE_ALARM_DISARMED) + await hass.async_block_till_done() + assert acc.char_target_state.value == 3 + assert acc.char_current_state.value == 3 + + hass.states.async_set(entity_id, STATE_ALARM_TRIGGERED) + await hass.async_block_till_done() + assert acc.char_target_state.value == 3 + assert acc.char_current_state.value == 4 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_target_state.value == 3 + assert acc.char_current_state.value == 4 + + # Set from HomeKit + call_arm_home = async_mock_service(hass, DOMAIN, 'alarm_arm_home') + call_arm_away = async_mock_service(hass, DOMAIN, 'alarm_arm_away') + call_arm_night = async_mock_service(hass, DOMAIN, 'alarm_arm_night') + call_disarm = async_mock_service(hass, DOMAIN, 'alarm_disarm') + + await hass.async_add_job(acc.char_target_state.client_update_value, 0) + await hass.async_block_till_done() + assert call_arm_home + assert call_arm_home[0].data[ATTR_ENTITY_ID] == entity_id + assert call_arm_home[0].data[ATTR_CODE] == code + assert acc.char_target_state.value == 0 + + await hass.async_add_job(acc.char_target_state.client_update_value, 1) + await hass.async_block_till_done() + assert call_arm_away + assert call_arm_away[0].data[ATTR_ENTITY_ID] == entity_id + assert call_arm_away[0].data[ATTR_CODE] == code + assert acc.char_target_state.value == 1 + + await hass.async_add_job(acc.char_target_state.client_update_value, 2) + await hass.async_block_till_done() + assert call_arm_night + assert call_arm_night[0].data[ATTR_ENTITY_ID] == entity_id + assert call_arm_night[0].data[ATTR_CODE] == code + assert acc.char_target_state.value == 2 + + await hass.async_add_job(acc.char_target_state.client_update_value, 3) + await hass.async_block_till_done() + assert call_disarm + assert call_disarm[0].data[ATTR_ENTITY_ID] == entity_id + assert call_disarm[0].data[ATTR_CODE] == code + assert acc.char_target_state.value == 3 + + +@pytest.mark.parametrize('config', [{}, {ATTR_CODE: None}]) +async def test_no_alarm_code(hass, hk_driver, config): + """Test accessory if security_system doesn't require an alarm_code.""" + entity_id = 'alarm_control_panel.test' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = SecuritySystem(hass, hk_driver, 'SecuritySystem', + entity_id, 2, config) + + # Set from HomeKit + call_arm_home = async_mock_service(hass, DOMAIN, 'alarm_arm_home') + + await hass.async_add_job(acc.char_target_state.client_update_value, 0) + await hass.async_block_till_done() + assert call_arm_home + assert call_arm_home[0].data[ATTR_ENTITY_ID] == entity_id + assert ATTR_CODE not in call_arm_home[0].data + assert acc.char_target_state.value == 0 diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py new file mode 100644 index 00000000000..54ecbcb196f --- /dev/null +++ b/tests/components/homekit/test_type_sensors.py @@ -0,0 +1,210 @@ +"""Test different accessory types: Sensors.""" +from homeassistant.components.homekit.const import PROP_CELSIUS +from homeassistant.components.homekit.type_sensors import ( + AirQualitySensor, BinarySensor, CarbonDioxideSensor, HumiditySensor, + LightSensor, TemperatureSensor, BINARY_SENSOR_SERVICE_MAP) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, STATE_HOME, STATE_NOT_HOME, + STATE_OFF, STATE_ON, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT) + + +async def test_temperature(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.temperature' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = TemperatureSensor(hass, hk_driver, 'Temperature', + entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_temp.value == 0.0 + for key, value in PROP_CELSIUS.items(): + assert acc.char_temp.properties[key] == value + + hass.states.async_set(entity_id, STATE_UNKNOWN, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_temp.value == 0.0 + + hass.states.async_set(entity_id, '20', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_temp.value == 20 + + hass.states.async_set(entity_id, '75.2', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) + await hass.async_block_till_done() + assert acc.char_temp.value == 24 + + +async def test_humidity(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.humidity' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = HumiditySensor(hass, hk_driver, 'Humidity', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_humidity.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_humidity.value == 0 + + hass.states.async_set(entity_id, '20') + await hass.async_block_till_done() + assert acc.char_humidity.value == 20 + + +async def test_air_quality(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.air_quality' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = AirQualitySensor(hass, hk_driver, 'Air Quality', + entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_density.value == 0 + assert acc.char_quality.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_density.value == 0 + assert acc.char_quality.value == 0 + + hass.states.async_set(entity_id, '34') + await hass.async_block_till_done() + assert acc.char_density.value == 34 + assert acc.char_quality.value == 1 + + hass.states.async_set(entity_id, '200') + await hass.async_block_till_done() + assert acc.char_density.value == 200 + assert acc.char_quality.value == 5 + + +async def test_co2(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.co2' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = CarbonDioxideSensor(hass, hk_driver, 'CO2', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_co2.value == 0 + assert acc.char_peak.value == 0 + assert acc.char_detected.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_co2.value == 0 + assert acc.char_peak.value == 0 + assert acc.char_detected.value == 0 + + hass.states.async_set(entity_id, '1100') + await hass.async_block_till_done() + assert acc.char_co2.value == 1100 + assert acc.char_peak.value == 1100 + assert acc.char_detected.value == 1 + + hass.states.async_set(entity_id, '800') + await hass.async_block_till_done() + assert acc.char_co2.value == 800 + assert acc.char_peak.value == 1100 + assert acc.char_detected.value == 0 + + +async def test_light(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.light' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = LightSensor(hass, hk_driver, 'Light', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_light.value == 0.0001 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_light.value == 0.0001 + + hass.states.async_set(entity_id, '300') + await hass.async_block_till_done() + assert acc.char_light.value == 300 + + +async def test_binary(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'binary_sensor.opening' + + hass.states.async_set(entity_id, STATE_UNKNOWN, + {ATTR_DEVICE_CLASS: 'opening'}) + await hass.async_block_till_done() + + acc = BinarySensor(hass, hk_driver, 'Window Opening', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_detected.value == 0 + + hass.states.async_set(entity_id, STATE_ON, + {ATTR_DEVICE_CLASS: 'opening'}) + await hass.async_block_till_done() + assert acc.char_detected.value == 1 + + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_DEVICE_CLASS: 'opening'}) + await hass.async_block_till_done() + assert acc.char_detected.value == 0 + + hass.states.async_set(entity_id, STATE_HOME, + {ATTR_DEVICE_CLASS: 'opening'}) + await hass.async_block_till_done() + assert acc.char_detected.value == 1 + + hass.states.async_set(entity_id, STATE_NOT_HOME, + {ATTR_DEVICE_CLASS: 'opening'}) + await hass.async_block_till_done() + assert acc.char_detected.value == 0 + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert acc.char_detected.value == 0 + + +async def test_binary_device_classes(hass, hk_driver): + """Test if services and characteristics are assigned correctly.""" + entity_id = 'binary_sensor.demo' + + for device_class, (service, char) in BINARY_SENSOR_SERVICE_MAP.items(): + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_DEVICE_CLASS: device_class}) + await hass.async_block_till_done() + + acc = BinarySensor(hass, hk_driver, 'Binary Sensor', + entity_id, 2, None) + assert acc.get_service(service).display_name == service + assert acc.char_detected.display_name == char diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py new file mode 100644 index 00000000000..3a09d2715d1 --- /dev/null +++ b/tests/components/homekit/test_type_switches.py @@ -0,0 +1,92 @@ +"""Test different accessory types: Switches.""" +import pytest + +from homeassistant.core import split_entity_id +from homeassistant.components.homekit.type_switches import Outlet, Switch +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON + +from tests.common import async_mock_service + + +async def test_outlet_set_state(hass, hk_driver): + """Test if Outlet accessory and HA are updated accordingly.""" + entity_id = 'switch.outlet_test' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Outlet(hass, hk_driver, 'Outlet', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 7 # Outlet + + assert acc.char_on.value is False + assert acc.char_outlet_in_use.value is True + + hass.states.async_set(entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_on.value is True + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.char_on.value is False + + # Set from HomeKit + call_turn_on = async_mock_service(hass, 'switch', 'turn_on') + call_turn_off = async_mock_service(hass, 'switch', 'turn_off') + + await hass.async_add_job(acc.char_on.client_update_value, True) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.char_on.client_update_value, False) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + + +@pytest.mark.parametrize('entity_id', [ + 'automation.test', + 'input_boolean.test', + 'remote.test', + 'script.test', + 'switch.test', +]) +async def test_switch_set_state(hass, hk_driver, entity_id): + """Test if accessory and HA are updated accordingly.""" + domain = split_entity_id(entity_id)[0] + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 8 # Switch + + assert acc.char_on.value is False + + hass.states.async_set(entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_on.value is True + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.char_on.value is False + + # Set from HomeKit + call_turn_on = async_mock_service(hass, domain, 'turn_on') + call_turn_off = async_mock_service(hass, domain, 'turn_off') + + await hass.async_add_job(acc.char_on.client_update_value, True) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.char_on.client_update_value, False) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py new file mode 100644 index 00000000000..00e3e2d22fc --- /dev/null +++ b/tests/components/homekit/test_type_thermostats.py @@ -0,0 +1,389 @@ +"""Test different accessory types: Thermostats.""" +from collections import namedtuple +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_TEMPERATURE, + ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH, ATTR_OPERATION_MODE, + ATTR_OPERATION_LIST, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, + STATE_AUTO, STATE_COOL, STATE_HEAT) +from homeassistant.components.homekit.const import ( + PROP_MAX_VALUE, PROP_MIN_VALUE) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, + STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) + +from tests.common import async_mock_service +from tests.components.homekit.common import patch_debounce + + +@pytest.fixture(scope='module') +def cls(): + """Patch debounce decorator during import of type_thermostats.""" + patcher = patch_debounce() + patcher.start() + _import = __import__('homeassistant.components.homekit.type_thermostats', + fromlist=['Thermostat']) + patcher_tuple = namedtuple('Cls', ['thermostat']) + yield patcher_tuple(thermostat=_import.Thermostat) + patcher.stop() + + +async def test_default_thermostat(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'climate.test' + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 9 # Thermostat + + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 0 + assert acc.char_current_temp.value == 21.0 + assert acc.char_target_temp.value == 21.0 + assert acc.char_display_units.value == 0 + assert acc.char_cooling_thresh_temp is None + assert acc.char_heating_thresh_temp is None + + assert acc.char_target_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_target_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP + + hass.states.async_set(entity_id, STATE_HEAT, + {ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 1 + assert acc.char_target_heat_cool.value == 1 + assert acc.char_current_temp.value == 18.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_HEAT, + {ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 23.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 1 + assert acc.char_current_temp.value == 23.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_COOL, + {ATTR_OPERATION_MODE: STATE_COOL, + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 25.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 20.0 + assert acc.char_current_heat_cool.value == 2 + assert acc.char_target_heat_cool.value == 2 + assert acc.char_current_temp.value == 25.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_COOL, + {ATTR_OPERATION_MODE: STATE_COOL, + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 19.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 20.0 + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 2 + assert acc.char_current_temp.value == 19.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_OPERATION_MODE: STATE_OFF, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 0 + assert acc.char_current_temp.value == 18.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_OPERATION_LIST: [STATE_HEAT, STATE_COOL], + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 1 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 18.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_OPERATION_LIST: [STATE_HEAT, STATE_COOL], + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 25.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 2 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 25.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_OPERATION_LIST: [STATE_HEAT, STATE_COOL], + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 22.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 22.0 + assert acc.char_display_units.value == 0 + + # Set from HomeKit + call_set_temperature = async_mock_service(hass, DOMAIN, 'set_temperature') + call_set_operation_mode = async_mock_service(hass, DOMAIN, + 'set_operation_mode') + + await hass.async_add_job(acc.char_target_temp.client_update_value, 19.0) + await hass.async_block_till_done() + assert call_set_temperature + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == 19.0 + assert acc.char_target_temp.value == 19.0 + + await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 1) + await hass.async_block_till_done() + assert call_set_operation_mode + assert call_set_operation_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_operation_mode[0].data[ATTR_OPERATION_MODE] == STATE_HEAT + assert acc.char_target_heat_cool.value == 1 + + +async def test_auto_thermostat(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'climate.test' + + # support_auto = True + hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] \ + == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] \ + == DEFAULT_MIN_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] \ + == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] \ + == DEFAULT_MIN_TEMP + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 20.0 + assert acc.char_cooling_thresh_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 1 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 18.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 24.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 19.0 + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_current_heat_cool.value == 2 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 24.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 19.0 + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 21.0 + assert acc.char_display_units.value == 0 + + # Set from HomeKit + call_set_temperature = async_mock_service(hass, DOMAIN, 'set_temperature') + + await hass.async_add_job( + acc.char_heating_thresh_temp.client_update_value, 20.0) + await hass.async_block_till_done() + assert call_set_temperature[0] + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 20.0 + assert acc.char_heating_thresh_temp.value == 20.0 + + await hass.async_add_job( + acc.char_cooling_thresh_temp.client_update_value, 25.0) + await hass.async_block_till_done() + assert call_set_temperature[1] + assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 25.0 + assert acc.char_cooling_thresh_temp.value == 25.0 + + +async def test_power_state(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'climate.test' + + # SUPPORT_ON_OFF = True + hass.states.async_set(entity_id, STATE_HEAT, + {ATTR_SUPPORTED_FEATURES: 4096, + ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 23.0, + ATTR_CURRENT_TEMPERATURE: 18.0}) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.support_power_state is True + + assert acc.char_current_heat_cool.value == 1 + assert acc.char_target_heat_cool.value == 1 + + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 23.0, + ATTR_CURRENT_TEMPERATURE: 18.0}) + await hass.async_block_till_done() + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 0 + + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_OPERATION_MODE: STATE_OFF, + ATTR_TEMPERATURE: 23.0, + ATTR_CURRENT_TEMPERATURE: 18.0}) + await hass.async_block_till_done() + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off') + call_set_operation_mode = async_mock_service(hass, DOMAIN, + 'set_operation_mode') + + await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 1) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_operation_mode + assert call_set_operation_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_operation_mode[0].data[ATTR_OPERATION_MODE] == STATE_HEAT + assert acc.char_target_heat_cool.value == 1 + + await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 0) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_target_heat_cool.value == 0 + + +async def test_thermostat_fahrenheit(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'climate.test' + + # support_auto = True + hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) + await hass.async_block_till_done() + with patch.object(hass.config.units, 'temperature_unit', + new=TEMP_FAHRENHEIT): + acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 75.2, + ATTR_TARGET_TEMP_LOW: 68, + ATTR_TEMPERATURE: 71.6, + ATTR_CURRENT_TEMPERATURE: 73.4, + ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 20.0 + assert acc.char_cooling_thresh_temp.value == 24.0 + assert acc.char_current_temp.value == 23.0 + assert acc.char_target_temp.value == 22.0 + assert acc.char_display_units.value == 1 + + # Set from HomeKit + call_set_temperature = async_mock_service(hass, DOMAIN, 'set_temperature') + + await hass.async_add_job( + acc.char_cooling_thresh_temp.client_update_value, 23) + await hass.async_block_till_done() + assert call_set_temperature[0] + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.4 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 68 + + await hass.async_add_job( + acc.char_heating_thresh_temp.client_update_value, 22) + await hass.async_block_till_done() + assert call_set_temperature[1] + assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.4 + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.6 + + await hass.async_add_job(acc.char_target_temp.client_update_value, 24.0) + await hass.async_block_till_done() + assert call_set_temperature[2] + assert call_set_temperature[2].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.2 + + +async def test_get_temperature_range(hass, hk_driver, cls): + """Test if temperature range is evaluated correctly.""" + entity_id = 'climate.test' + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25}) + await hass.async_block_till_done() + assert acc.get_temperature_range() == (20, 25) + + acc._unit = TEMP_FAHRENHEIT + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70}) + await hass.async_block_till_done() + assert acc.get_temperature_range() == (15.6, 21.1) diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py new file mode 100644 index 00000000000..fa9fddee5fc --- /dev/null +++ b/tests/components/homekit/test_util.py @@ -0,0 +1,133 @@ +"""Test HomeKit util module.""" +import pytest +import voluptuous as vol + +from homeassistant.core import State +from homeassistant.components.homekit.const import ( + CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF, + FEATURE_PLAY_PAUSE, TYPE_OUTLET) +from homeassistant.components.homekit.util import ( + convert_to_float, density_to_air_quality, dismiss_setup_message, + show_setup_message, temperature_to_homekit, temperature_to_states, + validate_media_player_features) +from homeassistant.components.homekit.util import validate_entity_config \ + as vec +from homeassistant.components.persistent_notification import ( + ATTR_MESSAGE, ATTR_NOTIFICATION_ID, DOMAIN) +from homeassistant.const import ( + ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, STATE_UNKNOWN, + TEMP_CELSIUS, TEMP_FAHRENHEIT) + +from tests.common import async_mock_service + + +def test_validate_entity_config(): + """Test validate entities.""" + configs = [{'invalid_entity_id': {}}, {'demo.test': 1}, + {'demo.test': 'test'}, {'demo.test': [1, 2]}, + {'demo.test': None}, {'demo.test': {CONF_NAME: None}}, + {'media_player.test': {CONF_FEATURE_LIST: [ + {CONF_FEATURE: 'invalid_feature'}]}}, + {'media_player.test': {CONF_FEATURE_LIST: [ + {CONF_FEATURE: FEATURE_ON_OFF}, + {CONF_FEATURE: FEATURE_ON_OFF}]}}, + {'switch.test': {CONF_TYPE: 'invalid_type'}}] + + for conf in configs: + with pytest.raises(vol.Invalid): + vec(conf) + + assert vec({}) == {} + assert vec({'demo.test': {CONF_NAME: 'Name'}}) == \ + {'demo.test': {CONF_NAME: 'Name'}} + + assert vec({'alarm_control_panel.demo': {}}) == \ + {'alarm_control_panel.demo': {ATTR_CODE: None}} + assert vec({'alarm_control_panel.demo': {ATTR_CODE: '1234'}}) == \ + {'alarm_control_panel.demo': {ATTR_CODE: '1234'}} + + assert vec({'lock.demo': {}}) == {'lock.demo': {ATTR_CODE: None}} + assert vec({'lock.demo': {ATTR_CODE: '1234'}}) == \ + {'lock.demo': {ATTR_CODE: '1234'}} + + assert vec({'media_player.demo': {}}) == \ + {'media_player.demo': {CONF_FEATURE_LIST: {}}} + config = {CONF_FEATURE_LIST: [{CONF_FEATURE: FEATURE_ON_OFF}, + {CONF_FEATURE: FEATURE_PLAY_PAUSE}]} + assert vec({'media_player.demo': config}) == \ + {'media_player.demo': {CONF_FEATURE_LIST: + {FEATURE_ON_OFF: {}, FEATURE_PLAY_PAUSE: {}}}} + assert vec({'switch.demo': {CONF_TYPE: TYPE_OUTLET}}) == \ + {'switch.demo': {CONF_TYPE: TYPE_OUTLET}} + + +def test_validate_media_player_features(): + """Test validate modes for media players.""" + config = {} + attrs = {ATTR_SUPPORTED_FEATURES: 20873} + entity_state = State('media_player.demo', 'on', attrs) + assert validate_media_player_features(entity_state, config) is True + + config = {FEATURE_ON_OFF: None} + assert validate_media_player_features(entity_state, config) is True + + entity_state = State('media_player.demo', 'on') + assert validate_media_player_features(entity_state, config) is False + + +def test_convert_to_float(): + """Test convert_to_float method.""" + assert convert_to_float(12) == 12 + assert convert_to_float(12.4) == 12.4 + assert convert_to_float(STATE_UNKNOWN) is None + assert convert_to_float(None) is None + + +def test_temperature_to_homekit(): + """Test temperature conversion from HA to HomeKit.""" + assert temperature_to_homekit(20.46, TEMP_CELSIUS) == 20.5 + assert temperature_to_homekit(92.1, TEMP_FAHRENHEIT) == 33.4 + + +def test_temperature_to_states(): + """Test temperature conversion from HomeKit to HA.""" + assert temperature_to_states(20, TEMP_CELSIUS) == 20.0 + assert temperature_to_states(20.2, TEMP_FAHRENHEIT) == 68.4 + + +def test_density_to_air_quality(): + """Test map PM2.5 density to HomeKit AirQuality level.""" + assert density_to_air_quality(0) == 1 + assert density_to_air_quality(35) == 1 + assert density_to_air_quality(35.1) == 2 + assert density_to_air_quality(75) == 2 + assert density_to_air_quality(115) == 3 + assert density_to_air_quality(150) == 4 + assert density_to_air_quality(300) == 5 + + +async def test_show_setup_msg(hass): + """Test show setup message as persistence notification.""" + pincode = b'123-45-678' + + call_create_notification = async_mock_service(hass, DOMAIN, 'create') + + await hass.async_add_job(show_setup_message, hass, pincode) + await hass.async_block_till_done() + + assert call_create_notification + assert call_create_notification[0].data[ATTR_NOTIFICATION_ID] == \ + HOMEKIT_NOTIFY_ID + assert pincode.decode() in call_create_notification[0].data[ATTR_MESSAGE] + + +async def test_dismiss_setup_msg(hass): + """Test dismiss setup message.""" + call_dismiss_notification = async_mock_service(hass, DOMAIN, 'dismiss') + + await hass.async_add_job(dismiss_setup_message, hass) + await hass.async_block_till_done() + + assert call_dismiss_notification + assert call_dismiss_notification[0].data[ATTR_NOTIFICATION_ID] == \ + HOMEKIT_NOTIFY_ID diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 604ee9c0c9b..a44d17d513d 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -55,19 +55,19 @@ async def test_auth_middleware_loaded_by_default(hass): assert len(mock_setup.mock_calls) == 1 -async def test_access_without_password(app, test_client): +async def test_access_without_password(app, aiohttp_client): """Test access without password.""" setup_auth(app, [], None) - client = await test_client(app) + client = await aiohttp_client(app) resp = await client.get('/') assert resp.status == 200 -async def test_access_with_password_in_header(app, test_client): +async def test_access_with_password_in_header(app, aiohttp_client): """Test access with password in URL.""" setup_auth(app, [], API_PASSWORD) - client = await test_client(app) + client = await aiohttp_client(app) req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) @@ -78,10 +78,10 @@ async def test_access_with_password_in_header(app, test_client): assert req.status == 401 -async def test_access_with_password_in_query(app, test_client): +async def test_access_with_password_in_query(app, aiohttp_client): """Test access without password.""" setup_auth(app, [], API_PASSWORD) - client = await test_client(app) + client = await aiohttp_client(app) resp = await client.get('/', params={ 'api_password': API_PASSWORD @@ -97,10 +97,10 @@ async def test_access_with_password_in_query(app, test_client): assert resp.status == 401 -async def test_basic_auth_works(app, test_client): +async def test_basic_auth_works(app, aiohttp_client): """Test access with basic authentication.""" setup_auth(app, [], API_PASSWORD) - client = await test_client(app) + client = await aiohttp_client(app) req = await client.get( '/', @@ -125,7 +125,7 @@ async def test_basic_auth_works(app, test_client): assert req.status == 401 -async def test_access_with_trusted_ip(test_client): +async def test_access_with_trusted_ip(aiohttp_client): """Test access with an untrusted ip address.""" app = web.Application() app.router.add_get('/', mock_handler) @@ -133,7 +133,7 @@ async def test_access_with_trusted_ip(test_client): setup_auth(app, TRUSTED_NETWORKS, 'some-pass') set_mock_ip = mock_real_ip(app) - client = await test_client(app) + client = await aiohttp_client(app) for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 2d7885d959f..c5691cf3e2a 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -15,7 +15,7 @@ from . import mock_real_ip BANNED_IPS = ['200.201.202.203', '100.64.0.2'] -async def test_access_from_banned_ip(hass, test_client): +async def test_access_from_banned_ip(hass, aiohttp_client): """Test accessing to server from banned IP. Both trusted and not.""" app = web.Application() setup_bans(hass, app, 5) @@ -24,7 +24,7 @@ async def test_access_from_banned_ip(hass, test_client): with patch('homeassistant.components.http.ban.load_ip_bans_config', return_value=[IpBan(banned_ip) for banned_ip in BANNED_IPS]): - client = await test_client(app) + client = await aiohttp_client(app) for remote_addr in BANNED_IPS: set_real_ip(remote_addr) @@ -54,7 +54,7 @@ async def test_ban_middleware_loaded_by_default(hass): assert len(mock_setup.mock_calls) == 1 -async def test_ip_bans_file_creation(hass, test_client): +async def test_ip_bans_file_creation(hass, aiohttp_client): """Testing if banned IP file created.""" app = web.Application() app['hass'] = hass @@ -70,7 +70,7 @@ async def test_ip_bans_file_creation(hass, test_client): with patch('homeassistant.components.http.ban.load_ip_bans_config', return_value=[IpBan(banned_ip) for banned_ip in BANNED_IPS]): - client = await test_client(app) + client = await aiohttp_client(app) m = mock_open() diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 50464b36277..27367b4173e 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -47,12 +47,12 @@ async def mock_handler(request): @pytest.fixture -def client(loop, test_client): +def client(loop, aiohttp_client): """Fixture to setup a web.Application.""" app = web.Application() app.router.add_get('/', mock_handler) setup_cors(app, [TRUSTED_ORIGIN]) - return loop.run_until_complete(test_client(app)) + return loop.run_until_complete(aiohttp_client(app)) async def test_cors_requests(client): diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py index 6cca1af8ccc..2b966daff6c 100644 --- a/tests/components/http/test_data_validator.py +++ b/tests/components/http/test_data_validator.py @@ -8,7 +8,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -async def get_client(test_client, validator): +async def get_client(aiohttp_client, validator): """Generate a client that hits a view decorated with validator.""" app = web.Application() app['hass'] = Mock(is_running=True) @@ -24,14 +24,14 @@ async def get_client(test_client, validator): return b'' TestView().register(app.router) - client = await test_client(app) + client = await aiohttp_client(app) return client -async def test_validator(test_client): +async def test_validator(aiohttp_client): """Test the validator.""" client = await get_client( - test_client, RequestDataValidator(vol.Schema({ + aiohttp_client, RequestDataValidator(vol.Schema({ vol.Required('test'): str }))) @@ -49,10 +49,10 @@ async def test_validator(test_client): assert resp.status == 400 -async def test_validator_allow_empty(test_client): +async def test_validator_allow_empty(aiohttp_client): """Test the validator with empty data.""" client = await get_client( - test_client, RequestDataValidator(vol.Schema({ + aiohttp_client, RequestDataValidator(vol.Schema({ # Although we allow empty, our schema should still be able # to validate an empty dict. vol.Optional('test'): str diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 1dcf45f48c3..d5368032a37 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,4 +1,6 @@ """The tests for the Home Assistant HTTP component.""" +import logging + from homeassistant.setup import async_setup_component import homeassistant.components.http as http @@ -15,12 +17,13 @@ class TestView(http.HomeAssistantView): return 'hello' -async def test_registering_view_while_running(hass, test_client, unused_port): +async def test_registering_view_while_running(hass, aiohttp_client, + aiohttp_unused_port): """Test that we can register a view while the server is running.""" await async_setup_component( hass, http.DOMAIN, { http.DOMAIN: { - http.CONF_SERVER_PORT: unused_port(), + http.CONF_SERVER_PORT: aiohttp_unused_port(), } } ) @@ -73,17 +76,15 @@ async def test_api_no_base_url(hass): assert hass.config.api.base_url == 'http://127.0.0.1:8123' -async def test_not_log_password(hass, unused_port, test_client, caplog): +async def test_not_log_password(hass, aiohttp_client, caplog): """Test access with password doesn't get logged.""" - result = await async_setup_component(hass, 'api', { + assert await async_setup_component(hass, 'api', { 'http': { - http.CONF_SERVER_PORT: unused_port(), http.CONF_API_PASSWORD: 'some-pass' } }) - assert result - - client = await test_client(hass.http.app) + client = await aiohttp_client(hass.http.app) + logging.getLogger('aiohttp.access').setLevel(logging.INFO) resp = await client.get('/api/', params={ 'api_password': 'some-pass' diff --git a/tests/components/http/test_real_ip.py b/tests/components/http/test_real_ip.py index 3e4f9023537..61846eb94c2 100644 --- a/tests/components/http/test_real_ip.py +++ b/tests/components/http/test_real_ip.py @@ -11,13 +11,13 @@ async def mock_handler(request): return web.Response(text=str(request[KEY_REAL_IP])) -async def test_ignore_x_forwarded_for(test_client): +async def test_ignore_x_forwarded_for(aiohttp_client): """Test that we get the IP from the transport.""" app = web.Application() app.router.add_get('/', mock_handler) setup_real_ip(app, False) - mock_api_client = await test_client(app) + mock_api_client = await aiohttp_client(app) resp = await mock_api_client.get('/', headers={ X_FORWARDED_FOR: '255.255.255.255' @@ -27,13 +27,13 @@ async def test_ignore_x_forwarded_for(test_client): assert text != '255.255.255.255' -async def test_use_x_forwarded_for(test_client): +async def test_use_x_forwarded_for(aiohttp_client): """Test that we get the IP from the transport.""" app = web.Application() app.router.add_get('/', mock_handler) setup_real_ip(app, True) - mock_api_client = await test_client(app) + mock_api_client = await aiohttp_client(app) resp = await mock_api_client.get('/', headers={ X_FORWARDED_FOR: '255.255.255.255' diff --git a/tests/components/hue/__init__.py b/tests/components/hue/__init__.py new file mode 100644 index 00000000000..8cff8700aaf --- /dev/null +++ b/tests/components/hue/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hue component.""" diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py new file mode 100644 index 00000000000..c20cee0d0e8 --- /dev/null +++ b/tests/components/hue/test_bridge.py @@ -0,0 +1,113 @@ +"""Test Hue bridge.""" +from unittest.mock import Mock, patch + +from homeassistant.components.hue import bridge, errors + +from tests.common import mock_coro + + +async def test_bridge_setup(): + """Test a successful setup.""" + hass = Mock() + entry = Mock() + api = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, 'get_bridge', return_value=mock_coro(api)): + assert await hue_bridge.async_setup() is True + + assert hue_bridge.api is api + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 + assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ + (entry, 'light') + + +async def test_bridge_setup_invalid_username(): + """Test we start config flow if username is no longer whitelisted.""" + hass = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, 'get_bridge', + side_effect=errors.AuthenticationRequired): + assert await hue_bridge.async_setup() is False + + assert len(hass.async_add_job.mock_calls) == 1 + assert len(hass.config_entries.flow.async_init.mock_calls) == 1 + assert hass.config_entries.flow.async_init.mock_calls[0][2]['data'] == { + 'host': '1.2.3.4' + } + + +async def test_bridge_setup_timeout(hass): + """Test we retry to connect if we cannot connect.""" + hass = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, 'get_bridge', side_effect=errors.CannotConnect): + assert await hue_bridge.async_setup() is False + + assert len(hass.helpers.event.async_call_later.mock_calls) == 1 + # Assert we are going to wait 2 seconds + assert hass.helpers.event.async_call_later.mock_calls[0][1][0] == 2 + + +async def test_reset_cancels_retry_setup(): + """Test resetting a bridge while we're waiting to retry setup.""" + hass = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, 'get_bridge', side_effect=errors.CannotConnect): + assert await hue_bridge.async_setup() is False + + mock_call_later = hass.helpers.event.async_call_later + + assert len(mock_call_later.mock_calls) == 1 + + assert await hue_bridge.async_reset() + + assert len(mock_call_later.mock_calls) == 2 + assert len(mock_call_later.return_value.mock_calls) == 1 + + +async def test_reset_if_entry_had_wrong_auth(): + """Test calling reset when the entry contained wrong auth.""" + hass = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, 'get_bridge', + side_effect=errors.AuthenticationRequired): + assert await hue_bridge.async_setup() is False + + assert len(hass.async_add_job.mock_calls) == 1 + + assert await hue_bridge.async_reset() + + +async def test_reset_unloads_entry_if_setup(): + """Test calling reset while the entry has been setup.""" + hass = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, 'get_bridge', return_value=mock_coro(Mock())): + assert await hue_bridge.async_setup() is True + + assert len(hass.services.async_register.mock_calls) == 1 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 + + hass.config_entries.async_forward_entry_unload.return_value = \ + mock_coro(True) + assert await hue_bridge.async_reset() + + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1 + assert len(hass.services.async_remove.mock_calls) == 1 diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py new file mode 100644 index 00000000000..fe3bffe5357 --- /dev/null +++ b/tests/components/hue/test_config_flow.py @@ -0,0 +1,355 @@ +"""Tests for Philips Hue config flow.""" +import asyncio +from unittest.mock import Mock, patch + +import aiohue +import pytest +import voluptuous as vol + +from homeassistant.components.hue import config_flow, const, errors + +from tests.common import MockConfigEntry, mock_coro + + +async def test_flow_works(hass, aioclient_mock): + """Test config flow .""" + aioclient_mock.get(const.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'} + ]) + + flow = config_flow.HueFlowHandler() + flow.hass = hass + await flow.async_step_init() + + with patch('aiohue.Bridge') as mock_bridge: + def mock_constructor(host, websession, username=None): + """Fake the bridge constructor.""" + mock_bridge.host = host + return mock_bridge + + mock_bridge.side_effect = mock_constructor + mock_bridge.username = 'username-abc' + mock_bridge.config.name = 'Mock Bridge' + mock_bridge.config.bridgeid = 'bridge-id-1234' + mock_bridge.create_user.return_value = mock_coro() + mock_bridge.initialize.return_value = mock_coro() + + result = await flow.async_step_link(user_input={}) + + assert mock_bridge.host == '1.2.3.4' + assert len(mock_bridge.create_user.mock_calls) == 1 + assert len(mock_bridge.initialize.mock_calls) == 1 + + assert result['type'] == 'create_entry' + assert result['title'] == 'Mock Bridge' + assert result['data'] == { + 'host': '1.2.3.4', + 'bridge_id': 'bridge-id-1234', + 'username': 'username-abc' + } + + +async def test_flow_no_discovered_bridges(hass, aioclient_mock): + """Test config flow discovers no bridges.""" + aioclient_mock.get(const.API_NUPNP, json=[]) + flow = config_flow.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock): + """Test config flow discovers only already configured bridges.""" + aioclient_mock.get(const.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'} + ]) + MockConfigEntry(domain='hue', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = config_flow.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +async def test_flow_one_bridge_discovered(hass, aioclient_mock): + """Test config flow discovers one bridge.""" + aioclient_mock.get(const.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'} + ]) + flow = config_flow.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_flow_two_bridges_discovered(hass, aioclient_mock): + """Test config flow discovers two bridges.""" + aioclient_mock.get(const.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'}, + {'internalipaddress': '5.6.7.8', 'id': 'beer'} + ]) + flow = config_flow.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'init' + + with pytest.raises(vol.Invalid): + assert result['data_schema']({'host': '0.0.0.0'}) + + result['data_schema']({'host': '1.2.3.4'}) + result['data_schema']({'host': '5.6.7.8'}) + + +async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock): + """Test config flow discovers two bridges.""" + aioclient_mock.get(const.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'}, + {'internalipaddress': '5.6.7.8', 'id': 'beer'} + ]) + MockConfigEntry(domain='hue', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = config_flow.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert flow.host == '5.6.7.8' + + +async def test_flow_timeout_discovery(hass): + """Test config flow .""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.discovery.discover_nupnp', + side_effect=asyncio.TimeoutError): + result = await flow.async_step_init() + + assert result['type'] == 'abort' + + +async def test_flow_link_timeout(hass): + """Test config flow .""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.Bridge.create_user', + side_effect=asyncio.TimeoutError): + result = await flow.async_step_link({}) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == { + 'base': 'linking' + } + + +async def test_flow_link_button_not_pressed(hass): + """Test config flow .""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.Bridge.create_user', + side_effect=aiohue.LinkButtonNotPressed): + result = await flow.async_step_link({}) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == { + 'base': 'register_failed' + } + + +async def test_flow_link_unknown_host(hass): + """Test config flow .""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.Bridge.create_user', + side_effect=aiohue.RequestError): + result = await flow.async_step_link({}) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == { + 'base': 'linking' + } + + +async def test_bridge_discovery(hass): + """Test a bridge being discovered.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + with patch.object(config_flow, 'get_bridge', + side_effect=errors.AuthenticationRequired): + result = await flow.async_step_discovery({ + 'host': '0.0.0.0', + 'serial': '1234' + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_bridge_discovery_emulated_hue(hass): + """Test if discovery info is from an emulated hue instance.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery({ + 'name': 'HASS Bridge', + 'host': '0.0.0.0', + 'serial': '1234' + }) + + assert result['type'] == 'abort' + + +async def test_bridge_discovery_already_configured(hass): + """Test if a discovered bridge has already been configured.""" + MockConfigEntry(domain='hue', data={ + 'host': '0.0.0.0' + }).add_to_hass(hass) + + flow = config_flow.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery({ + 'host': '0.0.0.0', + 'serial': '1234' + }) + + assert result['type'] == 'abort' + + +async def test_import_with_existing_config(hass): + """Test importing a host with an existing config file.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + bridge = Mock() + bridge.username = 'username-abc' + bridge.config.bridgeid = 'bridge-id-1234' + bridge.config.name = 'Mock Bridge' + bridge.host = '0.0.0.0' + + with patch.object(config_flow, '_find_username_from_config', + return_value='mock-user'), \ + patch.object(config_flow, 'get_bridge', + return_value=mock_coro(bridge)): + result = await flow.async_step_import({ + 'host': '0.0.0.0', + 'path': 'bla.conf' + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Mock Bridge' + assert result['data'] == { + 'host': '0.0.0.0', + 'bridge_id': 'bridge-id-1234', + 'username': 'username-abc' + } + + +async def test_import_with_no_config(hass): + """Test importing a host without an existing config file.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + with patch.object(config_flow, 'get_bridge', + side_effect=errors.AuthenticationRequired): + result = await flow.async_step_import({ + 'host': '0.0.0.0', + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_import_with_existing_but_invalid_config(hass): + """Test importing a host with a config file with invalid username.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + with patch.object(config_flow, '_find_username_from_config', + return_value='mock-user'), \ + patch.object(config_flow, 'get_bridge', + side_effect=errors.AuthenticationRequired): + result = await flow.async_step_import({ + 'host': '0.0.0.0', + 'path': 'bla.conf' + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_import_cannot_connect(hass): + """Test importing a host that we cannot conncet to.""" + flow = config_flow.HueFlowHandler() + flow.hass = hass + + with patch.object(config_flow, 'get_bridge', + side_effect=errors.CannotConnect): + result = await flow.async_step_import({ + 'host': '0.0.0.0', + }) + + assert result['type'] == 'abort' + assert result['reason'] == 'cannot_connect' + + +async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass): + """Test that we clean up entries for same host and bridge. + + An IP can only hold a single bridge and a single bridge can only be + accessible via a single IP. So when we create a new entry, we'll remove + all existing entries that either have same IP or same bridge_id. + """ + MockConfigEntry(domain='hue', data={ + 'host': '0.0.0.0', + 'bridge_id': 'id-1234' + }).add_to_hass(hass) + + MockConfigEntry(domain='hue', data={ + 'host': '1.2.3.4', + 'bridge_id': 'id-1234' + }).add_to_hass(hass) + + assert len(hass.config_entries.async_entries('hue')) == 2 + + flow = config_flow.HueFlowHandler() + flow.hass = hass + + bridge = Mock() + bridge.username = 'username-abc' + bridge.config.bridgeid = 'id-1234' + bridge.config.name = 'Mock Bridge' + bridge.host = '0.0.0.0' + + with patch.object(config_flow, 'get_bridge', + return_value=mock_coro(bridge)): + result = await flow.async_step_import({ + 'host': '0.0.0.0', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Mock Bridge' + assert result['data'] == { + 'host': '0.0.0.0', + 'bridge_id': 'id-1234', + 'username': 'username-abc' + } + # We did not process the result of this entry but already removed the old + # ones. So we should have 0 entries. + assert len(hass.config_entries.async_entries('hue')) == 0 diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py new file mode 100644 index 00000000000..ea656ba8fc6 --- /dev/null +++ b/tests/components/hue/test_init.py @@ -0,0 +1,188 @@ +"""Test Hue setup process.""" +from unittest.mock import patch + +from homeassistant.setup import async_setup_component +from homeassistant.components import hue + +from tests.common import mock_coro, MockConfigEntry + + +async def test_setup_with_no_config(hass): + """Test that we do not discover anything or try to setup a bridge.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hue, 'configured_hosts', return_value=[]): + assert await async_setup_component(hass, hue.DOMAIN, {}) is True + + # No flows started + assert len(mock_config_entries.flow.mock_calls) == 0 + + # No configs stored + assert hass.data[hue.DOMAIN] == {} + + +async def test_setup_with_discovery_no_known_auth(hass, aioclient_mock): + """Test discovering a bridge and not having known auth.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + { + 'internalipaddress': '0.0.0.0', + 'id': 'abcd1234' + } + ]) + + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hue, 'configured_hosts', return_value=[]): + mock_config_entries.flow.async_init.return_value = mock_coro() + assert await async_setup_component(hass, hue.DOMAIN, { + hue.DOMAIN: {} + }) is True + + # Flow started for discovered bridge + assert len(mock_config_entries.flow.mock_calls) == 1 + assert mock_config_entries.flow.mock_calls[0][2]['data'] == { + 'host': '0.0.0.0', + 'path': '.hue_abcd1234.conf', + } + + # Config stored for domain. + assert hass.data[hue.DOMAIN] == { + '0.0.0.0': { + hue.CONF_HOST: '0.0.0.0', + hue.CONF_FILENAME: '.hue_abcd1234.conf', + hue.CONF_ALLOW_HUE_GROUPS: hue.DEFAULT_ALLOW_HUE_GROUPS, + hue.CONF_ALLOW_UNREACHABLE: hue.DEFAULT_ALLOW_UNREACHABLE, + } + } + + +async def test_setup_with_discovery_known_auth(hass, aioclient_mock): + """Test we don't do anything if we discover already configured hub.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + { + 'internalipaddress': '0.0.0.0', + 'id': 'abcd1234' + } + ]) + + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hue, 'configured_hosts', return_value=['0.0.0.0']): + assert await async_setup_component(hass, hue.DOMAIN, { + hue.DOMAIN: {} + }) is True + + # Flow started for discovered bridge + assert len(mock_config_entries.flow.mock_calls) == 0 + + # Config stored for domain. + assert hass.data[hue.DOMAIN] == {} + + +async def test_setup_defined_hosts_known_auth(hass): + """Test we don't initiate a config entry if config bridge is known.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hue, 'configured_hosts', return_value=['0.0.0.0']): + assert await async_setup_component(hass, hue.DOMAIN, { + hue.DOMAIN: { + hue.CONF_BRIDGES: { + hue.CONF_HOST: '0.0.0.0', + hue.CONF_FILENAME: 'bla.conf', + hue.CONF_ALLOW_HUE_GROUPS: False, + hue.CONF_ALLOW_UNREACHABLE: True + } + } + }) is True + + # Flow started for discovered bridge + assert len(mock_config_entries.flow.mock_calls) == 0 + + # Config stored for domain. + assert hass.data[hue.DOMAIN] == { + '0.0.0.0': { + hue.CONF_HOST: '0.0.0.0', + hue.CONF_FILENAME: 'bla.conf', + hue.CONF_ALLOW_HUE_GROUPS: False, + hue.CONF_ALLOW_UNREACHABLE: True + } + } + + +async def test_setup_defined_hosts_no_known_auth(hass): + """Test we initiate config entry if config bridge is not known.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hue, 'configured_hosts', return_value=[]): + mock_config_entries.flow.async_init.return_value = mock_coro() + assert await async_setup_component(hass, hue.DOMAIN, { + hue.DOMAIN: { + hue.CONF_BRIDGES: { + hue.CONF_HOST: '0.0.0.0', + hue.CONF_FILENAME: 'bla.conf', + hue.CONF_ALLOW_HUE_GROUPS: False, + hue.CONF_ALLOW_UNREACHABLE: True + } + } + }) is True + + # Flow started for discovered bridge + assert len(mock_config_entries.flow.mock_calls) == 1 + assert mock_config_entries.flow.mock_calls[0][2]['data'] == { + 'host': '0.0.0.0', + 'path': 'bla.conf', + } + + # Config stored for domain. + assert hass.data[hue.DOMAIN] == { + '0.0.0.0': { + hue.CONF_HOST: '0.0.0.0', + hue.CONF_FILENAME: 'bla.conf', + hue.CONF_ALLOW_HUE_GROUPS: False, + hue.CONF_ALLOW_UNREACHABLE: True + } + } + + +async def test_config_passed_to_config_entry(hass): + """Test that configured options for a host are loaded via config entry.""" + entry = MockConfigEntry(domain=hue.DOMAIN, data={ + 'host': '0.0.0.0', + }) + entry.add_to_hass(hass) + + with patch.object(hue, 'HueBridge') as mock_bridge: + mock_bridge.return_value.async_setup.return_value = mock_coro(True) + assert await async_setup_component(hass, hue.DOMAIN, { + hue.DOMAIN: { + hue.CONF_BRIDGES: { + hue.CONF_HOST: '0.0.0.0', + hue.CONF_FILENAME: 'bla.conf', + hue.CONF_ALLOW_HUE_GROUPS: False, + hue.CONF_ALLOW_UNREACHABLE: True + } + } + }) is True + + assert len(mock_bridge.mock_calls) == 2 + p_hass, p_entry, p_allow_unreachable, p_allow_groups = \ + mock_bridge.mock_calls[0][1] + + assert p_hass is hass + assert p_entry is entry + assert p_allow_unreachable is True + assert p_allow_groups is False + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = MockConfigEntry(domain=hue.DOMAIN, data={ + 'host': '0.0.0.0', + }) + entry.add_to_hass(hass) + + with patch.object(hue, 'HueBridge') as mock_bridge: + mock_bridge.return_value.async_setup.return_value = mock_coro(True) + assert await async_setup_component(hass, hue.DOMAIN, {}) is True + + assert len(mock_bridge.return_value.mock_calls) == 1 + + mock_bridge.return_value.async_reset.return_value = mock_coro(True) + assert await hue.async_unload_entry(hass, entry) + assert len(mock_bridge.return_value.async_reset.mock_calls) == 1 + assert hass.data[hue.DOMAIN] == {} diff --git a/tests/components/image_processing/test_facebox.py b/tests/components/image_processing/test_facebox.py new file mode 100644 index 00000000000..cdc19a3d8d1 --- /dev/null +++ b/tests/components/image_processing/test_facebox.py @@ -0,0 +1,139 @@ +"""The tests for the facebox component.""" +from unittest.mock import patch + +import pytest +import requests +import requests_mock + +from homeassistant.core import callback +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_FRIENDLY_NAME, + CONF_IP_ADDRESS, CONF_PORT, STATE_UNKNOWN) +from homeassistant.setup import async_setup_component +import homeassistant.components.image_processing as ip +import homeassistant.components.image_processing.facebox as fb + +MOCK_IP = '192.168.0.1' +MOCK_PORT = '8080' + +MOCK_FACE = {'confidence': 0.5812028911604818, + 'id': 'john.jpg', + 'matched': True, + 'name': 'John Lennon', + 'rect': {'height': 75, 'left': 63, 'top': 262, 'width': 74} + } + +MOCK_JSON = {"facesCount": 1, + "success": True, + "faces": [MOCK_FACE] + } + +VALID_ENTITY_ID = 'image_processing.facebox_demo_camera' +VALID_CONFIG = { + ip.DOMAIN: { + 'platform': 'facebox', + CONF_IP_ADDRESS: MOCK_IP, + CONF_PORT: MOCK_PORT, + ip.CONF_SOURCE: { + ip.CONF_ENTITY_ID: 'camera.demo_camera'} + }, + 'camera': { + 'platform': 'demo' + } + } + + +def test_encode_image(): + """Test that binary data is encoded correctly.""" + assert fb.encode_image(b'test')["base64"] == 'dGVzdA==' + + +def test_get_matched_faces(): + """Test that matched faces are parsed correctly.""" + assert fb.get_matched_faces([MOCK_FACE]) == {MOCK_FACE['name']: 0.58} + + +@pytest.fixture +def mock_image(): + """Return a mock camera image.""" + with patch('homeassistant.components.camera.demo.DemoCamera.camera_image', + return_value=b'Test') as image: + yield image + + +async def test_setup_platform(hass): + """Setup platform with one entity.""" + await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + assert hass.states.get(VALID_ENTITY_ID) + + +async def test_process_image(hass, mock_image): + """Test processing of an image.""" + await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + assert hass.states.get(VALID_ENTITY_ID) + + face_events = [] + + @callback + def mock_face_event(event): + """Mock event.""" + face_events.append(event) + + hass.bus.async_listen('image_processing.detect_face', mock_face_event) + + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT) + mock_req.post(url, json=MOCK_JSON) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} + await hass.services.async_call(ip.DOMAIN, + ip.SERVICE_SCAN, + service_data=data) + await hass.async_block_till_done() + + state = hass.states.get(VALID_ENTITY_ID) + assert state.state == '1' + assert state.attributes.get('matched_faces') == {MOCK_FACE['name']: 0.58} + + MOCK_FACE[ATTR_ENTITY_ID] = VALID_ENTITY_ID # Update. + assert state.attributes.get('faces') == [MOCK_FACE] + assert state.attributes.get(CONF_FRIENDLY_NAME) == 'facebox demo_camera' + + assert len(face_events) == 1 + assert face_events[0].data['name'] == MOCK_FACE['name'] + assert face_events[0].data['confidence'] == MOCK_FACE['confidence'] + assert face_events[0].data['entity_id'] == VALID_ENTITY_ID + + +async def test_connection_error(hass, mock_image): + """Test connection error.""" + await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + assert hass.states.get(VALID_ENTITY_ID) + + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT) + mock_req.register_uri( + 'POST', url, exc=requests.exceptions.ConnectTimeout) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} + await hass.services.async_call(ip.DOMAIN, + ip.SERVICE_SCAN, + service_data=data) + await hass.async_block_till_done() + + state = hass.states.get(VALID_ENTITY_ID) + assert state.state == STATE_UNKNOWN + assert state.attributes.get('faces') == [] + assert state.attributes.get('matched_faces') == {} + + +async def test_setup_platform_with_name(hass): + """Setup platform with one entity and a name.""" + MOCK_NAME = 'mock_name' + NAMED_ENTITY_ID = 'image_processing.{}'.format(MOCK_NAME) + + VALID_CONFIG_NAMED = VALID_CONFIG.copy() + VALID_CONFIG_NAMED[ip.DOMAIN][ip.CONF_SOURCE][ip.CONF_NAME] = MOCK_NAME + + await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG_NAMED) + assert hass.states.get(NAMED_ENTITY_ID) + state = hass.states.get(NAMED_ENTITY_ID) + assert state.attributes.get(CONF_FRIENDLY_NAME) == MOCK_NAME diff --git a/tests/components/image_processing/test_openalpr_cloud.py b/tests/components/image_processing/test_openalpr_cloud.py index e840bce54f7..50060e08a4b 100644 --- a/tests/components/image_processing/test_openalpr_cloud.py +++ b/tests/components/image_processing/test_openalpr_cloud.py @@ -3,14 +3,13 @@ import asyncio from unittest.mock import patch, PropertyMock from homeassistant.core import callback -from homeassistant.const import ATTR_ENTITY_PICTURE from homeassistant.setup import setup_component -import homeassistant.components.image_processing as ip +from homeassistant.components import camera, image_processing as ip from homeassistant.components.image_processing.openalpr_cloud import ( OPENALPR_API_URL) from tests.common import ( - get_test_home_assistant, assert_setup_component, load_fixture) + get_test_home_assistant, assert_setup_component, load_fixture, mock_coro) class TestOpenAlprCloudSetup(object): @@ -131,11 +130,6 @@ class TestOpenAlprCloud(object): new_callable=PropertyMock(return_value=False)): setup_component(self.hass, ip.DOMAIN, config) - state = self.hass.states.get('camera.demo_camera') - self.url = "{0}{1}".format( - self.hass.config.api.base_url, - state.attributes.get(ATTR_ENTITY_PICTURE)) - self.alpr_events = [] @callback @@ -158,18 +152,20 @@ class TestOpenAlprCloud(object): def test_openalpr_process_image(self, aioclient_mock): """Setup and scan a picture and test plates from event.""" - aioclient_mock.get(self.url, content=b'image') aioclient_mock.post( OPENALPR_API_URL, params=self.params, text=load_fixture('alpr_cloud.json'), status=200 ) - ip.scan(self.hass, entity_id='image_processing.test_local') - self.hass.block_till_done() + with patch('homeassistant.components.camera.async_get_image', + return_value=mock_coro( + camera.Image('image/jpeg', b'image'))): + ip.scan(self.hass, entity_id='image_processing.test_local') + self.hass.block_till_done() state = self.hass.states.get('image_processing.test_local') - assert len(aioclient_mock.mock_calls) == 2 + assert len(aioclient_mock.mock_calls) == 1 assert len(self.alpr_events) == 5 assert state.attributes.get('vehicles') == 1 assert state.state == 'H786P0J' @@ -184,28 +180,32 @@ class TestOpenAlprCloud(object): def test_openalpr_process_image_api_error(self, aioclient_mock): """Setup and scan a picture and test api error.""" - aioclient_mock.get(self.url, content=b'image') aioclient_mock.post( OPENALPR_API_URL, params=self.params, text="{'error': 'error message'}", status=400 ) - ip.scan(self.hass, entity_id='image_processing.test_local') - self.hass.block_till_done() + with patch('homeassistant.components.camera.async_get_image', + return_value=mock_coro( + camera.Image('image/jpeg', b'image'))): + ip.scan(self.hass, entity_id='image_processing.test_local') + self.hass.block_till_done() - assert len(aioclient_mock.mock_calls) == 2 + assert len(aioclient_mock.mock_calls) == 1 assert len(self.alpr_events) == 0 def test_openalpr_process_image_api_timeout(self, aioclient_mock): """Setup and scan a picture and test api error.""" - aioclient_mock.get(self.url, content=b'image') aioclient_mock.post( OPENALPR_API_URL, params=self.params, exc=asyncio.TimeoutError() ) - ip.scan(self.hass, entity_id='image_processing.test_local') - self.hass.block_till_done() + with patch('homeassistant.components.camera.async_get_image', + return_value=mock_coro( + camera.Image('image/jpeg', b'image'))): + ip.scan(self.hass, entity_id='image_processing.test_local') + self.hass.block_till_done() - assert len(aioclient_mock.mock_calls) == 2 + assert len(aioclient_mock.mock_calls) == 1 assert len(self.alpr_events) == 0 diff --git a/tests/components/light/test_deconz.py b/tests/components/light/test_deconz.py new file mode 100644 index 00000000000..2608d77ce2a --- /dev/null +++ b/tests/components/light/test_deconz.py @@ -0,0 +1,100 @@ +"""deCONZ light platform tests.""" +from unittest.mock import Mock, patch + +from homeassistant import config_entries +from homeassistant.components import deconz +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import mock_coro + + +LIGHT = { + "1": { + "id": "Light 1 id", + "name": "Light 1 name", + "state": {} + } +} + +GROUP = { + "1": { + "id": "Group 1 id", + "name": "Group 1 name", + "state": {}, + "action": {}, + "scenes": [], + "lights": [ + "1", + "2" + ] + }, + "2": { + "id": "Group 2 id", + "name": "Group 2 name", + "state": {}, + "action": {}, + "scenes": [] + }, +} + + +async def setup_bridge(hass, data): + """Load the deCONZ light platform.""" + from pydeconz import DeconzSession + loop = Mock() + session = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + bridge = DeconzSession(loop, session, **entry.data) + with patch('pydeconz.DeconzSession.async_get_state', + return_value=mock_coro(data)): + await bridge.async_load_parameters() + hass.data[deconz.DOMAIN] = bridge + hass.data[deconz.DATA_DECONZ_UNSUB] = [] + hass.data[deconz.DATA_DECONZ_ID] = {} + config_entry = config_entries.ConfigEntry( + 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test') + await hass.config_entries.async_forward_entry_setup(config_entry, 'light') + # To flush out the service call to update the group + await hass.async_block_till_done() + + +async def test_no_lights_or_groups(hass): + """Test that no lights or groups entities are created.""" + data = {} + await setup_bridge(hass, data) + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + assert len(hass.states.async_all()) == 0 + + +async def test_lights_and_groups(hass): + """Test that lights or groups entities are created.""" + await setup_bridge(hass, {"lights": LIGHT, "groups": GROUP}) + assert "light.light_1_name" in hass.data[deconz.DATA_DECONZ_ID] + assert "light.group_1_name" in hass.data[deconz.DATA_DECONZ_ID] + assert "light.group_2_name" not in hass.data[deconz.DATA_DECONZ_ID] + assert len(hass.states.async_all()) == 3 + + +async def test_add_new_light(hass): + """Test successful creation of light entities.""" + data = {} + await setup_bridge(hass, data) + light = Mock() + light.name = 'name' + light.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_light', [light]) + await hass.async_block_till_done() + assert "light.name" in hass.data[deconz.DATA_DECONZ_ID] + + +async def test_add_new_group(hass): + """Test successful creation of group entities.""" + data = {} + await setup_bridge(hass, data) + group = Mock() + group.name = 'name' + group.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_group', [group]) + await hass.async_block_till_done() + assert "light.name" in hass.data[deconz.DATA_DECONZ_ID] diff --git a/tests/components/light/test_demo.py b/tests/components/light/test_demo.py index 8a7d648e6f2..8ba6385166b 100644 --- a/tests/components/light/test_demo.py +++ b/tests/components/light/test_demo.py @@ -29,28 +29,31 @@ class TestDemoLight(unittest.TestCase): def test_state_attributes(self): """Test light state attributes.""" light.turn_on( - self.hass, ENTITY_LIGHT, xy_color=(.4, .6), brightness=25) + self.hass, ENTITY_LIGHT, xy_color=(.4, .4), brightness=25) self.hass.block_till_done() state = self.hass.states.get(ENTITY_LIGHT) self.assertTrue(light.is_on(self.hass, ENTITY_LIGHT)) - self.assertEqual((.4, .6), state.attributes.get(light.ATTR_XY_COLOR)) + self.assertEqual((0.4, 0.4), state.attributes.get( + light.ATTR_XY_COLOR)) self.assertEqual(25, state.attributes.get(light.ATTR_BRIGHTNESS)) self.assertEqual( - (76, 95, 0), state.attributes.get(light.ATTR_RGB_COLOR)) + (255, 234, 164), state.attributes.get(light.ATTR_RGB_COLOR)) self.assertEqual('rainbow', state.attributes.get(light.ATTR_EFFECT)) light.turn_on( - self.hass, ENTITY_LIGHT, rgb_color=(251, 252, 253), + self.hass, ENTITY_LIGHT, rgb_color=(251, 253, 255), white_value=254) self.hass.block_till_done() state = self.hass.states.get(ENTITY_LIGHT) self.assertEqual(254, state.attributes.get(light.ATTR_WHITE_VALUE)) self.assertEqual( - (251, 252, 253), state.attributes.get(light.ATTR_RGB_COLOR)) + (250, 252, 255), state.attributes.get(light.ATTR_RGB_COLOR)) + self.assertEqual( + (0.319, 0.326), state.attributes.get(light.ATTR_XY_COLOR)) light.turn_on(self.hass, ENTITY_LIGHT, color_temp=400, effect='none') self.hass.block_till_done() state = self.hass.states.get(ENTITY_LIGHT) self.assertEqual(400, state.attributes.get(light.ATTR_COLOR_TEMP)) - self.assertEqual(154, state.attributes.get(light.ATTR_MIN_MIREDS)) + self.assertEqual(153, state.attributes.get(light.ATTR_MIN_MIREDS)) self.assertEqual(500, state.attributes.get(light.ATTR_MAX_MIREDS)) self.assertEqual('none', state.attributes.get(light.ATTR_EFFECT)) light.turn_on(self.hass, ENTITY_LIGHT, kelvin=3000, brightness_pct=50) diff --git a/tests/components/light/test_group.py b/tests/components/light/test_group.py index 3c94fa2af3e..26b949720d9 100644 --- a/tests/components/light/test_group.py +++ b/tests/components/light/test_group.py @@ -20,8 +20,7 @@ async def test_default_state(hass): assert state.state == 'unavailable' assert state.attributes['supported_features'] == 0 assert state.attributes.get('brightness') is None - assert state.attributes.get('rgb_color') is None - assert state.attributes.get('xy_color') is None + assert state.attributes.get('hs_color') is None assert state.attributes.get('color_temp') is None assert state.attributes.get('white_value') is None assert state.attributes.get('effect_list') is None @@ -85,61 +84,32 @@ async def test_brightness(hass): assert state.attributes['brightness'] == 100 -async def test_xy_color(hass): - """Test XY reporting.""" - await async_setup_component(hass, 'light', {'light': { - 'platform': 'group', 'entities': ['light.test1', 'light.test2'] - }}) - - hass.states.async_set('light.test1', 'on', - {'xy_color': (1.0, 1.0), 'supported_features': 64}) - await hass.async_block_till_done() - state = hass.states.get('light.light_group') - assert state.state == 'on' - assert state.attributes['supported_features'] == 64 - assert state.attributes['xy_color'] == (1.0, 1.0) - - hass.states.async_set('light.test2', 'on', - {'xy_color': (0.5, 0.5), 'supported_features': 64}) - await hass.async_block_till_done() - state = hass.states.get('light.light_group') - assert state.state == 'on' - assert state.attributes['xy_color'] == (0.75, 0.75) - - hass.states.async_set('light.test1', 'off', - {'xy_color': (1.0, 1.0), 'supported_features': 64}) - await hass.async_block_till_done() - state = hass.states.get('light.light_group') - assert state.state == 'on' - assert state.attributes['xy_color'] == (0.5, 0.5) - - -async def test_rgb_color(hass): +async def test_color(hass): """Test RGB reporting.""" await async_setup_component(hass, 'light', {'light': { 'platform': 'group', 'entities': ['light.test1', 'light.test2'] }}) hass.states.async_set('light.test1', 'on', - {'rgb_color': (255, 0, 0), 'supported_features': 16}) + {'hs_color': (0, 100), 'supported_features': 16}) await hass.async_block_till_done() state = hass.states.get('light.light_group') assert state.state == 'on' assert state.attributes['supported_features'] == 16 - assert state.attributes['rgb_color'] == (255, 0, 0) + assert state.attributes['hs_color'] == (0, 100) hass.states.async_set('light.test2', 'on', - {'rgb_color': (255, 255, 255), + {'hs_color': (0, 50), 'supported_features': 16}) await hass.async_block_till_done() state = hass.states.get('light.light_group') - assert state.attributes['rgb_color'] == (255, 127, 127) + assert state.attributes['hs_color'] == (0, 75) hass.states.async_set('light.test1', 'off', - {'rgb_color': (255, 0, 0), 'supported_features': 16}) + {'hs_color': (0, 0), 'supported_features': 16}) await hass.async_block_till_done() state = hass.states.get('light.light_group') - assert state.attributes['rgb_color'] == (255, 255, 255) + assert state.attributes['hs_color'] == (0, 50) async def test_white_value(hass): @@ -413,5 +383,7 @@ async def test_invalid_service_calls(hass): } await grouped_light.async_turn_on(**data) data['entity_id'] = ['light.test1', 'light.test2'] + data.pop('rgb_color') + data.pop('xy_color') mock_call.assert_called_once_with('light', 'turn_on', data, blocking=True) diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index 559467d5e9a..a1e3867f9c3 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -1,545 +1,678 @@ """Philips Hue lights platform tests.""" - +import asyncio +from collections import deque import logging -import unittest -import unittest.mock as mock -from unittest.mock import call, MagicMock, patch +from unittest.mock import Mock +import aiohue +from aiohue.lights import Lights +from aiohue.groups import Groups +import pytest + +from homeassistant import config_entries from homeassistant.components import hue import homeassistant.components.light.hue as hue_light - -from tests.common import get_test_home_assistant, MockDependency +from homeassistant.util import color _LOGGER = logging.getLogger(__name__) HUE_LIGHT_NS = 'homeassistant.components.light.hue.' - - -class TestSetup(unittest.TestCase): - """Test the Hue light platform.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.skip_teardown_stop = False - - def tearDown(self): - """Stop everything that was started.""" - if not self.skip_teardown_stop: - self.hass.stop() - - def setup_mocks_for_update_lights(self): - """Set up all mocks for update_lights tests.""" - self.mock_bridge = MagicMock() - self.mock_bridge.bridge_id = 'bridge-id' - self.mock_bridge.allow_hue_groups = False - self.mock_api = MagicMock() - self.mock_bridge.get_api.return_value = self.mock_api - self.mock_add_devices = MagicMock() - - def setup_mocks_for_process_lights(self): - """Set up all mocks for process_lights tests.""" - self.mock_bridge = self.create_mock_bridge('host') - self.mock_api = MagicMock() - self.mock_api.get.return_value = {} - self.mock_bridge.get_api.return_value = self.mock_api - - def setup_mocks_for_process_groups(self): - """Set up all mocks for process_groups tests.""" - self.mock_bridge = self.create_mock_bridge('host') - self.mock_bridge.get_group.return_value = { - 'name': 'Group 0', 'state': {'any_on': True}} - - self.mock_api = MagicMock() - self.mock_api.get.return_value = {} - self.mock_bridge.get_api.return_value = self.mock_api - - def create_mock_bridge(self, host, allow_hue_groups=True): - """Return a mock HueBridge with reasonable defaults.""" - mock_bridge = MagicMock() - mock_bridge.bridge_id = 'bridge-id' - mock_bridge.host = host - mock_bridge.allow_hue_groups = allow_hue_groups - mock_bridge.lights = {} - mock_bridge.lightgroups = {} - return mock_bridge - - def create_mock_lights(self, lights): - """Return a dict suitable for mocking api.get('lights').""" - mock_bridge_lights = lights - - for info in mock_bridge_lights.values(): - if 'state' not in info: - info['state'] = {'on': False} - - return mock_bridge_lights - - def build_mock_light(self, bridge, light_id, name): - """Return a mock HueLight.""" - light = MagicMock() - light.bridge = bridge - light.light_id = light_id - light.name = name - return light - - def test_setup_platform_no_discovery_info(self): - """Test setup_platform without discovery info.""" - self.hass.data[hue.DOMAIN] = {} - mock_add_devices = MagicMock() - - hue_light.setup_platform(self.hass, {}, mock_add_devices) - - mock_add_devices.assert_not_called() - - def test_setup_platform_no_bridge_id(self): - """Test setup_platform without a bridge.""" - self.hass.data[hue.DOMAIN] = {} - mock_add_devices = MagicMock() - - hue_light.setup_platform(self.hass, {}, mock_add_devices, {}) - - mock_add_devices.assert_not_called() - - def test_setup_platform_one_bridge(self): - """Test setup_platform with one bridge.""" - mock_bridge = MagicMock() - self.hass.data[hue.DOMAIN] = {'10.0.0.1': mock_bridge} - mock_add_devices = MagicMock() - - with patch(HUE_LIGHT_NS + 'unthrottled_update_lights') \ - as mock_update_lights: - hue_light.setup_platform( - self.hass, {}, mock_add_devices, - {'bridge_id': '10.0.0.1'}) - mock_update_lights.assert_called_once_with( - self.hass, mock_bridge, mock_add_devices) - - def test_setup_platform_multiple_bridges(self): - """Test setup_platform wuth multiple bridges.""" - mock_bridge = MagicMock() - mock_bridge2 = MagicMock() - self.hass.data[hue.DOMAIN] = { - '10.0.0.1': mock_bridge, - '192.168.0.10': mock_bridge2, +GROUP_RESPONSE = { + "1": { + "name": "Group 1", + "lights": [ + "1", + "2" + ], + "type": "LightGroup", + "action": { + "on": True, + "bri": 254, + "hue": 10000, + "sat": 254, + "effect": "none", + "xy": [ + 0.5, + 0.5 + ], + "ct": 250, + "alert": "select", + "colormode": "ct" + }, + "state": { + "any_on": True, + "all_on": False, } - mock_add_devices = MagicMock() - - with patch(HUE_LIGHT_NS + 'unthrottled_update_lights') \ - as mock_update_lights: - hue_light.setup_platform( - self.hass, {}, mock_add_devices, - {'bridge_id': '10.0.0.1'}) - hue_light.setup_platform( - self.hass, {}, mock_add_devices, - {'bridge_id': '192.168.0.10'}) - - mock_update_lights.assert_has_calls([ - call(self.hass, mock_bridge, mock_add_devices), - call(self.hass, mock_bridge2, mock_add_devices), - ]) - - @MockDependency('phue') - def test_update_lights_with_no_lights(self, mock_phue): - """Test the update_lights function when no lights are found.""" - self.setup_mocks_for_update_lights() - - with patch(HUE_LIGHT_NS + 'process_lights', return_value=[]) \ - as mock_process_lights: - with patch(HUE_LIGHT_NS + 'process_groups', return_value=[]) \ - as mock_process_groups: - with patch.object(self.hass.helpers.dispatcher, - 'dispatcher_send') as dispatcher_send: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) - - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - mock_process_groups.assert_not_called() - self.mock_add_devices.assert_not_called() - dispatcher_send.assert_not_called() - - @MockDependency('phue') - def test_update_lights_with_some_lights(self, mock_phue): - """Test the update_lights function with some lights.""" - self.setup_mocks_for_update_lights() - mock_lights = [ - self.build_mock_light(self.mock_bridge, 42, 'some'), - self.build_mock_light(self.mock_bridge, 84, 'light'), - ] - - with patch(HUE_LIGHT_NS + 'process_lights', - return_value=mock_lights) as mock_process_lights: - with patch(HUE_LIGHT_NS + 'process_groups', return_value=[]) \ - as mock_process_groups: - with patch.object(self.hass.helpers.dispatcher, - 'dispatcher_send') as dispatcher_send: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) - - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - mock_process_groups.assert_not_called() - self.mock_add_devices.assert_called_once_with( - mock_lights) - dispatcher_send.assert_not_called() - - @MockDependency('phue') - def test_update_lights_no_groups(self, mock_phue): - """Test the update_lights function when no groups are found.""" - self.setup_mocks_for_update_lights() - self.mock_bridge.allow_hue_groups = True - mock_lights = [ - self.build_mock_light(self.mock_bridge, 42, 'some'), - self.build_mock_light(self.mock_bridge, 84, 'light'), - ] - - with patch(HUE_LIGHT_NS + 'process_lights', - return_value=mock_lights) as mock_process_lights: - with patch(HUE_LIGHT_NS + 'process_groups', return_value=[]) \ - as mock_process_groups: - with patch.object(self.hass.helpers.dispatcher, - 'dispatcher_send') as dispatcher_send: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) - - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - mock_process_groups.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - self.mock_add_devices.assert_called_once_with( - mock_lights) - dispatcher_send.assert_not_called() - - @MockDependency('phue') - def test_update_lights_with_lights_and_groups(self, mock_phue): - """Test the update_lights function with both lights and groups.""" - self.setup_mocks_for_update_lights() - self.mock_bridge.allow_hue_groups = True - mock_lights = [ - self.build_mock_light(self.mock_bridge, 42, 'some'), - self.build_mock_light(self.mock_bridge, 84, 'light'), - ] - mock_groups = [ - self.build_mock_light(self.mock_bridge, 15, 'and'), - self.build_mock_light(self.mock_bridge, 72, 'groups'), - ] - - with patch(HUE_LIGHT_NS + 'process_lights', - return_value=mock_lights) as mock_process_lights: - with patch(HUE_LIGHT_NS + 'process_groups', - return_value=mock_groups) as mock_process_groups: - with patch.object(self.hass.helpers.dispatcher, - 'dispatcher_send') as dispatcher_send: - hue_light.unthrottled_update_lights( - self.hass, self.mock_bridge, self.mock_add_devices) - - mock_process_lights.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - mock_process_groups.assert_called_once_with( - self.hass, self.mock_api, self.mock_bridge, mock.ANY) - # note that mock_lights has been modified in place and - # now contains both lights and groups - self.mock_add_devices.assert_called_once_with( - mock_lights) - dispatcher_send.assert_not_called() - - @MockDependency('phue') - def test_update_lights_with_two_bridges(self, mock_phue): - """Test the update_lights function with two bridges.""" - self.setup_mocks_for_update_lights() - - mock_bridge_one = self.create_mock_bridge('one', False) - mock_bridge_one_lights = self.create_mock_lights( - {1: {'name': 'b1l1'}, 2: {'name': 'b1l2'}}) - - mock_bridge_two = self.create_mock_bridge('two', False) - mock_bridge_two_lights = self.create_mock_lights( - {1: {'name': 'b2l1'}, 3: {'name': 'b2l3'}}) - - with patch('homeassistant.components.light.hue.HueLight.' - 'schedule_update_ha_state'): - mock_api = MagicMock() - mock_api.get.return_value = mock_bridge_one_lights - with patch.object(mock_bridge_one, 'get_api', - return_value=mock_api): - hue_light.unthrottled_update_lights( - self.hass, mock_bridge_one, self.mock_add_devices) - - mock_api = MagicMock() - mock_api.get.return_value = mock_bridge_two_lights - with patch.object(mock_bridge_two, 'get_api', - return_value=mock_api): - hue_light.unthrottled_update_lights( - self.hass, mock_bridge_two, self.mock_add_devices) - - self.assertEqual(sorted(mock_bridge_one.lights.keys()), [1, 2]) - self.assertEqual(sorted(mock_bridge_two.lights.keys()), [1, 3]) - - self.assertEqual(len(self.mock_add_devices.mock_calls), 2) - - # first call - name, args, kwargs = self.mock_add_devices.mock_calls[0] - self.assertEqual(len(args), 1) - self.assertEqual(len(kwargs), 0) - - # second call works the same - name, args, kwargs = self.mock_add_devices.mock_calls[1] - self.assertEqual(len(args), 1) - self.assertEqual(len(kwargs), 0) - - def test_process_lights_api_error(self): - """Test the process_lights function when the bridge errors out.""" - self.setup_mocks_for_process_lights() - self.mock_api.get.return_value = None - - ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual([], ret) - self.assertEqual(self.mock_bridge.lights, {}) - - def test_process_lights_no_lights(self): - """Test the process_lights function when bridge returns no lights.""" - self.setup_mocks_for_process_lights() - - with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ - as mock_dispatcher_send: - ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual([], ret) - mock_dispatcher_send.assert_not_called() - self.assertEqual(self.mock_bridge.lights, {}) - - @patch(HUE_LIGHT_NS + 'HueLight') - def test_process_lights_some_lights(self, mock_hue_light): - """Test the process_lights function with multiple groups.""" - self.setup_mocks_for_process_lights() - self.mock_api.get.return_value = { - 1: {'state': 'on'}, 2: {'state': 'off'}} - - with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ - as mock_dispatcher_send: - ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual(len(ret), 2) - mock_hue_light.assert_has_calls([ - call( - 1, {'state': 'on'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue), - call( - 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue), - ]) - mock_dispatcher_send.assert_not_called() - self.assertEqual(len(self.mock_bridge.lights), 2) - - @patch(HUE_LIGHT_NS + 'HueLight') - def test_process_lights_new_light(self, mock_hue_light): - """ - Test the process_lights function with new groups. - - Test what happens when we already have a light and a new one shows up. - """ - self.setup_mocks_for_process_lights() - self.mock_api.get.return_value = { - 1: {'state': 'on'}, 2: {'state': 'off'}} - self.mock_bridge.lights = { - 1: self.build_mock_light(self.mock_bridge, 1, 'foo')} - - with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ - as mock_dispatcher_send: - ret = hue_light.process_lights( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual(len(ret), 1) - mock_hue_light.assert_has_calls([ - call( - 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue), - ]) - mock_dispatcher_send.assert_called_once_with( - 'hue_light_callback_bridge-id_1') - self.assertEqual(len(self.mock_bridge.lights), 2) - - def test_process_groups_api_error(self): - """Test the process_groups function when the bridge errors out.""" - self.setup_mocks_for_process_groups() - self.mock_api.get.return_value = None - - ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual([], ret) - self.assertEqual(self.mock_bridge.lightgroups, {}) - - def test_process_groups_no_state(self): - """Test the process_groups function when bridge returns no status.""" - self.setup_mocks_for_process_groups() - self.mock_bridge.get_group.return_value = {'name': 'Group 0'} - - with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ - as mock_dispatcher_send: - ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual([], ret) - mock_dispatcher_send.assert_not_called() - self.assertEqual(self.mock_bridge.lightgroups, {}) - - @patch(HUE_LIGHT_NS + 'HueLight') - def test_process_groups_some_groups(self, mock_hue_light): - """Test the process_groups function with multiple groups.""" - self.setup_mocks_for_process_groups() - self.mock_api.get.return_value = { - 1: {'state': 'on'}, 2: {'state': 'off'}} - - with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ - as mock_dispatcher_send: - ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual(len(ret), 2) - mock_hue_light.assert_has_calls([ - call( - 1, {'state': 'on'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue, True), - call( - 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue, True), - ]) - mock_dispatcher_send.assert_not_called() - self.assertEqual(len(self.mock_bridge.lightgroups), 2) - - @patch(HUE_LIGHT_NS + 'HueLight') - def test_process_groups_new_group(self, mock_hue_light): - """ - Test the process_groups function with new groups. - - Test what happens when we already have a light and a new one shows up. - """ - self.setup_mocks_for_process_groups() - self.mock_api.get.return_value = { - 1: {'state': 'on'}, 2: {'state': 'off'}} - self.mock_bridge.lightgroups = { - 1: self.build_mock_light(self.mock_bridge, 1, 'foo')} - - with patch.object(self.hass.helpers.dispatcher, 'dispatcher_send') \ - as mock_dispatcher_send: - ret = hue_light.process_groups( - self.hass, self.mock_api, self.mock_bridge, None) - - self.assertEqual(len(ret), 1) - mock_hue_light.assert_has_calls([ - call( - 2, {'state': 'off'}, self.mock_bridge, mock.ANY, - self.mock_bridge.allow_unreachable, - self.mock_bridge.allow_in_emulated_hue, True), - ]) - mock_dispatcher_send.assert_called_once_with( - 'hue_light_callback_bridge-id_1') - self.assertEqual(len(self.mock_bridge.lightgroups), 2) + }, + "2": { + "name": "Group 2", + "lights": [ + "3", + "4", + "5" + ], + "type": "LightGroup", + "action": { + "on": True, + "bri": 153, + "hue": 4345, + "sat": 254, + "effect": "none", + "xy": [ + 0.5, + 0.5 + ], + "ct": 250, + "alert": "select", + "colormode": "ct" + }, + "state": { + "any_on": True, + "all_on": False, + } + } +} +LIGHT_1_ON = { + "state": { + "on": True, + "bri": 144, + "hue": 13088, + "sat": 212, + "xy": [0.5128, 0.4147], + "ct": 467, + "alert": "none", + "effect": "none", + "colormode": "xy", + "reachable": True + }, + "type": "Extended color light", + "name": "Hue Lamp 1", + "modelid": "LCT001", + "swversion": "66009461", + "manufacturername": "Philips", + "uniqueid": "456", +} +LIGHT_1_OFF = { + "state": { + "on": False, + "bri": 0, + "hue": 0, + "sat": 0, + "xy": [0, 0], + "ct": 0, + "alert": "none", + "effect": "none", + "colormode": "xy", + "reachable": True + }, + "type": "Extended color light", + "name": "Hue Lamp 1", + "modelid": "LCT001", + "swversion": "66009461", + "manufacturername": "Philips", + "uniqueid": "456", +} +LIGHT_2_OFF = { + "state": { + "on": False, + "bri": 0, + "hue": 0, + "sat": 0, + "xy": [0, 0], + "ct": 0, + "alert": "none", + "effect": "none", + "colormode": "hs", + "reachable": True + }, + "type": "Extended color light", + "name": "Hue Lamp 2", + "modelid": "LCT001", + "swversion": "66009461", + "manufacturername": "Philips", + "uniqueid": "123", +} +LIGHT_2_ON = { + "state": { + "on": True, + "bri": 100, + "hue": 13088, + "sat": 210, + "xy": [.5, .4], + "ct": 420, + "alert": "none", + "effect": "none", + "colormode": "hs", + "reachable": True + }, + "type": "Extended color light", + "name": "Hue Lamp 2 new", + "modelid": "LCT001", + "swversion": "66009461", + "manufacturername": "Philips", + "uniqueid": "123", +} +LIGHT_RESPONSE = { + "1": LIGHT_1_ON, + "2": LIGHT_2_OFF, +} -class TestHueLight(unittest.TestCase): - """Test the HueLight class.""" +@pytest.fixture +def mock_bridge(hass): + """Mock a Hue bridge.""" + bridge = Mock( + available=True, + allow_unreachable=False, + allow_groups=False, + api=Mock(), + spec=hue.HueBridge + ) + bridge.mock_requests = [] + # We're using a deque so we can schedule multiple responses + # and also means that `popleft()` will blow up if we get more updates + # than expected. + bridge.mock_light_responses = deque() + bridge.mock_group_responses = deque() - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.skip_teardown_stop = False + async def mock_request(method, path, **kwargs): + kwargs['method'] = method + kwargs['path'] = path + bridge.mock_requests.append(kwargs) - self.light_id = 42 - self.mock_info = MagicMock() - self.mock_bridge = MagicMock() - self.mock_update_lights = MagicMock() - self.mock_allow_unreachable = MagicMock() - self.mock_is_group = MagicMock() - self.mock_allow_in_emulated_hue = MagicMock() - self.mock_is_group = False + if path == 'lights': + return bridge.mock_light_responses.popleft() + elif path == 'groups': + return bridge.mock_group_responses.popleft() + return None - def tearDown(self): - """Stop everything that was started.""" - if not self.skip_teardown_stop: - self.hass.stop() + bridge.api.config.apiversion = '9.9.9' + bridge.api.lights = Lights({}, mock_request) + bridge.api.groups = Groups({}, mock_request) - def buildLight( - self, light_id=None, info=None, update_lights=None, is_group=None): - """Helper to build a HueLight object with minimal fuss.""" - if 'state' not in info: - on_key = 'any_on' if is_group is not None else 'on' - info['state'] = {on_key: False} + return bridge - return hue_light.HueLight( - light_id if light_id is not None else self.light_id, - info if info is not None else self.mock_info, - self.mock_bridge, - (update_lights - if update_lights is not None - else self.mock_update_lights), - self.mock_allow_unreachable, self.mock_allow_in_emulated_hue, - is_group if is_group is not None else self.mock_is_group) - def test_unique_id_for_light(self): - """Test the unique_id method with lights.""" - light = self.buildLight(info={'uniqueid': 'foobar'}) - self.assertEqual('foobar', light.unique_id) +async def setup_bridge(hass, mock_bridge): + """Load the Hue light platform with the provided bridge.""" + hass.config.components.add(hue.DOMAIN) + hass.data[hue.DOMAIN] = {'mock-host': mock_bridge} + config_entry = config_entries.ConfigEntry(1, hue.DOMAIN, 'Mock Title', { + 'host': 'mock-host' + }, 'test') + await hass.config_entries.async_forward_entry_setup(config_entry, 'light') + # To flush out the service call to update the group + await hass.async_block_till_done() - light = self.buildLight(info={}) - self.assertIsNone(light.unique_id) - def test_unique_id_for_group(self): - """Test the unique_id method with groups.""" - light = self.buildLight(info={'uniqueid': 'foobar'}, is_group=True) - self.assertEqual('foobar', light.unique_id) +async def test_not_load_groups_if_old_bridge(hass, mock_bridge): + """Test that we don't try to load gorups if bridge runs old software.""" + mock_bridge.api.config.apiversion = '1.12.0' + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 0 - light = self.buildLight(info={}, is_group=True) - self.assertIsNone(light.unique_id) + +async def test_no_lights_or_groups(hass, mock_bridge): + """Test the update_lights function when no lights are found.""" + mock_bridge.allow_groups = True + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append({}) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 2 + assert len(hass.states.async_all()) == 0 + + +async def test_lights(hass, mock_bridge): + """Test the update_lights function with some lights.""" + mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + # 1 All Lights group, 2 lights + assert len(hass.states.async_all()) == 3 + + lamp_1 = hass.states.get('light.hue_lamp_1') + assert lamp_1 is not None + assert lamp_1.state == 'on' + assert lamp_1.attributes['brightness'] == 144 + assert lamp_1.attributes['hs_color'] == (36.067, 69.804) + + lamp_2 = hass.states.get('light.hue_lamp_2') + assert lamp_2 is not None + assert lamp_2.state == 'off' + + +async def test_lights_color_mode(hass, mock_bridge): + """Test that lights only report appropriate color mode.""" + mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) + await setup_bridge(hass, mock_bridge) + + lamp_1 = hass.states.get('light.hue_lamp_1') + assert lamp_1 is not None + assert lamp_1.state == 'on' + assert lamp_1.attributes['brightness'] == 144 + assert lamp_1.attributes['hs_color'] == (36.067, 69.804) + assert 'color_temp' not in lamp_1.attributes + + new_light1_on = LIGHT_1_ON.copy() + new_light1_on['state'] = new_light1_on['state'].copy() + new_light1_on['state']['colormode'] = 'ct' + mock_bridge.mock_light_responses.append({ + "1": new_light1_on, + }) + mock_bridge.mock_group_responses.append({}) + + # Calling a service will trigger the updates to run + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.hue_lamp_2' + }, blocking=True) + # 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 3 + + lamp_1 = hass.states.get('light.hue_lamp_1') + assert lamp_1 is not None + assert lamp_1.state == 'on' + assert lamp_1.attributes['brightness'] == 144 + assert lamp_1.attributes['color_temp'] == 467 + assert 'hs_color' not in lamp_1.attributes + + +async def test_groups(hass, mock_bridge): + """Test the update_lights function with some lights.""" + mock_bridge.allow_groups = True + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 2 + # 1 all lights group, 2 hue group lights + assert len(hass.states.async_all()) == 3 + + lamp_1 = hass.states.get('light.group_1') + assert lamp_1 is not None + assert lamp_1.state == 'on' + assert lamp_1.attributes['brightness'] == 254 + assert lamp_1.attributes['color_temp'] == 250 + + lamp_2 = hass.states.get('light.group_2') + assert lamp_2 is not None + assert lamp_2.state == 'on' + + +async def test_new_group_discovered(hass, mock_bridge): + """Test if 2nd update has a new group.""" + mock_bridge.allow_groups = True + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 2 + assert len(hass.states.async_all()) == 3 + + new_group_response = dict(GROUP_RESPONSE) + new_group_response['3'] = { + "name": "Group 3", + "lights": [ + "3", + "4", + "5" + ], + "type": "LightGroup", + "action": { + "on": True, + "bri": 153, + "hue": 4345, + "sat": 254, + "effect": "none", + "xy": [ + 0.5, + 0.5 + ], + "ct": 250, + "alert": "select", + "colormode": "ct" + }, + "state": { + "any_on": True, + "all_on": False, + } + } + + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append(new_group_response) + + # Calling a service will trigger the updates to run + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.group_1' + }, blocking=True) + # 2x group update, 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 5 + assert len(hass.states.async_all()) == 4 + + new_group = hass.states.get('light.group_3') + assert new_group is not None + assert new_group.state == 'on' + assert new_group.attributes['brightness'] == 153 + assert new_group.attributes['color_temp'] == 250 + + +async def test_new_light_discovered(hass, mock_bridge): + """Test if 2nd update has a new light.""" + mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 3 + + new_light_response = dict(LIGHT_RESPONSE) + new_light_response['3'] = { + "state": { + "on": False, + "bri": 0, + "hue": 0, + "sat": 0, + "xy": [0, 0], + "ct": 0, + "alert": "none", + "effect": "none", + "colormode": "hs", + "reachable": True + }, + "type": "Extended color light", + "name": "Hue Lamp 3", + "modelid": "LCT001", + "swversion": "66009461", + "manufacturername": "Philips", + "uniqueid": "789", + } + + mock_bridge.mock_light_responses.append(new_light_response) + + # Calling a service will trigger the updates to run + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.hue_lamp_1' + }, blocking=True) + # 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 3 + assert len(hass.states.async_all()) == 4 + + light = hass.states.get('light.hue_lamp_3') + assert light is not None + assert light.state == 'off' + + +async def test_other_group_update(hass, mock_bridge): + """Test changing one group that will impact the state of other light.""" + mock_bridge.allow_groups = True + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append(GROUP_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 2 + assert len(hass.states.async_all()) == 3 + + group_2 = hass.states.get('light.group_2') + assert group_2 is not None + assert group_2.name == 'Group 2' + assert group_2.state == 'on' + assert group_2.attributes['brightness'] == 153 + assert group_2.attributes['color_temp'] == 250 + + updated_group_response = dict(GROUP_RESPONSE) + updated_group_response['2'] = { + "name": "Group 2 new", + "lights": [ + "3", + "4", + "5" + ], + "type": "LightGroup", + "action": { + "on": False, + "bri": 0, + "hue": 0, + "sat": 0, + "effect": "none", + "xy": [ + 0, + 0 + ], + "ct": 0, + "alert": "none", + "colormode": "ct" + }, + "state": { + "any_on": False, + "all_on": False, + } + } + + mock_bridge.mock_light_responses.append({}) + mock_bridge.mock_group_responses.append(updated_group_response) + + # Calling a service will trigger the updates to run + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.group_1' + }, blocking=True) + # 2x group update, 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 5 + assert len(hass.states.async_all()) == 3 + + group_2 = hass.states.get('light.group_2') + assert group_2 is not None + assert group_2.name == 'Group 2 new' + assert group_2.state == 'off' + + +async def test_other_light_update(hass, mock_bridge): + """Test changing one light that will impact state of other light.""" + mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 3 + + lamp_2 = hass.states.get('light.hue_lamp_2') + assert lamp_2 is not None + assert lamp_2.name == 'Hue Lamp 2' + assert lamp_2.state == 'off' + + updated_light_response = dict(LIGHT_RESPONSE) + updated_light_response['2'] = { + "state": { + "on": True, + "bri": 100, + "hue": 13088, + "sat": 210, + "xy": [.5, .4], + "ct": 420, + "alert": "none", + "effect": "none", + "colormode": "hs", + "reachable": True + }, + "type": "Extended color light", + "name": "Hue Lamp 2 new", + "modelid": "LCT001", + "swversion": "66009461", + "manufacturername": "Philips", + "uniqueid": "123", + } + + mock_bridge.mock_light_responses.append(updated_light_response) + + # Calling a service will trigger the updates to run + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.hue_lamp_1' + }, blocking=True) + # 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 3 + assert len(hass.states.async_all()) == 3 + + lamp_2 = hass.states.get('light.hue_lamp_2') + assert lamp_2 is not None + assert lamp_2.name == 'Hue Lamp 2 new' + assert lamp_2.state == 'on' + assert lamp_2.attributes['brightness'] == 100 + + +async def test_update_timeout(hass, mock_bridge): + """Test bridge marked as not available if timeout error during update.""" + mock_bridge.api.lights.update = Mock(side_effect=asyncio.TimeoutError) + mock_bridge.api.groups.update = Mock(side_effect=asyncio.TimeoutError) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 0 + assert len(hass.states.async_all()) == 0 + assert mock_bridge.available is False + + +async def test_update_unauthorized(hass, mock_bridge): + """Test bridge marked as not available if unauthorized during update.""" + mock_bridge.api.lights.update = Mock(side_effect=aiohue.Unauthorized) + mock_bridge.api.groups.update = Mock(side_effect=aiohue.Unauthorized) + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 0 + assert len(hass.states.async_all()) == 0 + assert mock_bridge.available is False + + +async def test_light_turn_on_service(hass, mock_bridge): + """Test calling the turn on service on a light.""" + mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) + await setup_bridge(hass, mock_bridge) + light = hass.states.get('light.hue_lamp_2') + assert light is not None + assert light.state == 'off' + + updated_light_response = dict(LIGHT_RESPONSE) + updated_light_response['2'] = LIGHT_2_ON + + mock_bridge.mock_light_responses.append(updated_light_response) + + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.hue_lamp_2', + 'brightness': 100, + 'color_temp': 300, + }, blocking=True) + # 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 3 + + assert mock_bridge.mock_requests[1]['json'] == { + 'bri': 100, + 'on': True, + 'ct': 300, + 'effect': 'none', + 'alert': 'none', + } + + assert len(hass.states.async_all()) == 3 + + light = hass.states.get('light.hue_lamp_2') + assert light is not None + assert light.state == 'on' + + +async def test_light_turn_off_service(hass, mock_bridge): + """Test calling the turn on service on a light.""" + mock_bridge.mock_light_responses.append(LIGHT_RESPONSE) + await setup_bridge(hass, mock_bridge) + light = hass.states.get('light.hue_lamp_1') + assert light is not None + assert light.state == 'on' + + updated_light_response = dict(LIGHT_RESPONSE) + updated_light_response['1'] = LIGHT_1_OFF + + mock_bridge.mock_light_responses.append(updated_light_response) + + await hass.services.async_call('light', 'turn_off', { + 'entity_id': 'light.hue_lamp_1', + }, blocking=True) + # 2x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 3 + + assert mock_bridge.mock_requests[1]['json'] == { + 'on': False, + 'alert': 'none', + } + + assert len(hass.states.async_all()) == 3 + + light = hass.states.get('light.hue_lamp_1') + assert light is not None + assert light.state == 'off' def test_available(): """Test available property.""" light = hue_light.HueLight( - info={'state': {'reachable': False}}, - allow_unreachable=False, + light=Mock(state={'reachable': False}), + request_bridge_update=None, + bridge=Mock(allow_unreachable=False), is_group=False, - - light_id=None, - bridge=mock.Mock(), - update_lights_cb=None, - allow_in_emulated_hue=False, ) assert light.available is False light = hue_light.HueLight( - info={'state': {'reachable': False}}, - allow_unreachable=True, + light=Mock(state={'reachable': False}), + request_bridge_update=None, + bridge=Mock(allow_unreachable=True), is_group=False, - - light_id=None, - bridge=mock.Mock(), - update_lights_cb=None, - allow_in_emulated_hue=False, ) assert light.available is True light = hue_light.HueLight( - info={'state': {'reachable': False}}, - allow_unreachable=False, + light=Mock(state={'reachable': False}), + request_bridge_update=None, + bridge=Mock(allow_unreachable=False), is_group=True, - - light_id=None, - bridge=mock.Mock(), - update_lights_cb=None, - allow_in_emulated_hue=False, ) assert light.available is True + + +def test_hs_color(): + """Test hs_color property.""" + light = hue_light.HueLight( + light=Mock(state={ + 'colormode': 'ct', + 'hue': 1234, + 'sat': 123, + }), + request_bridge_update=None, + bridge=Mock(), + is_group=False, + ) + + assert light.hs_color is None + + light = hue_light.HueLight( + light=Mock(state={ + 'colormode': 'hs', + 'hue': 1234, + 'sat': 123, + }), + request_bridge_update=None, + bridge=Mock(), + is_group=False, + ) + + assert light.hs_color is None + + light = hue_light.HueLight( + light=Mock(state={ + 'colormode': 'xy', + 'hue': 1234, + 'sat': 123, + 'xy': [0.4, 0.5] + }), + request_bridge_update=None, + bridge=Mock(), + is_group=False, + ) + + assert light.hs_color == color.color_xy_to_hs(0.4, 0.5) diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index d35321b4479..634e3774b8a 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -118,7 +118,7 @@ class TestLight(unittest.TestCase): def test_services(self): """Test the provided services.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( @@ -188,23 +188,25 @@ class TestLight(unittest.TestCase): self.hass.block_till_done() _, data = dev1.last_call('turn_on') - self.assertEqual( - {light.ATTR_TRANSITION: 10, - light.ATTR_BRIGHTNESS: 20, - light.ATTR_RGB_COLOR: (0, 0, 255)}, - data) + self.assertEqual({ + light.ATTR_TRANSITION: 10, + light.ATTR_BRIGHTNESS: 20, + light.ATTR_HS_COLOR: (240, 100), + }, data) _, data = dev2.last_call('turn_on') - self.assertEqual( - {light.ATTR_RGB_COLOR: (255, 255, 255), - light.ATTR_WHITE_VALUE: 255}, - data) + self.assertEqual({ + light.ATTR_HS_COLOR: (0, 0), + light.ATTR_WHITE_VALUE: 255, + }, data) _, data = dev3.last_call('turn_on') - self.assertEqual({light.ATTR_XY_COLOR: (.4, .6)}, data) + self.assertEqual({ + light.ATTR_HS_COLOR: (71.059, 100), + }, data) # One of the light profiles - prof_name, prof_x, prof_y, prof_bri = 'relax', 0.5119, 0.4147, 144 + prof_name, prof_h, prof_s, prof_bri = 'relax', 35.932, 69.412, 144 # Test light profiles light.turn_on(self.hass, dev1.entity_id, profile=prof_name) @@ -216,16 +218,16 @@ class TestLight(unittest.TestCase): self.hass.block_till_done() _, data = dev1.last_call('turn_on') - self.assertEqual( - {light.ATTR_BRIGHTNESS: prof_bri, - light.ATTR_XY_COLOR: (prof_x, prof_y)}, - data) + self.assertEqual({ + light.ATTR_BRIGHTNESS: prof_bri, + light.ATTR_HS_COLOR: (prof_h, prof_s), + }, data) _, data = dev2.last_call('turn_on') - self.assertEqual( - {light.ATTR_BRIGHTNESS: 100, - light.ATTR_XY_COLOR: (.5119, .4147)}, - data) + self.assertEqual({ + light.ATTR_BRIGHTNESS: 100, + light.ATTR_HS_COLOR: (prof_h, prof_s), + }, data) # Test bad data light.turn_on(self.hass) @@ -265,7 +267,7 @@ class TestLight(unittest.TestCase): def test_broken_light_profiles(self): """Test light profiles.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() user_light_file = self.hass.config.path(light.LIGHT_PROFILES_FILE) @@ -280,7 +282,7 @@ class TestLight(unittest.TestCase): def test_light_profiles(self): """Test light profiles.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() user_light_file = self.hass.config.path(light.LIGHT_PROFILES_FILE) @@ -301,15 +303,16 @@ class TestLight(unittest.TestCase): _, data = dev1.last_call('turn_on') - self.assertEqual( - {light.ATTR_XY_COLOR: (.4, .6), light.ATTR_BRIGHTNESS: 100}, - data) + self.assertEqual({ + light.ATTR_HS_COLOR: (71.059, 100), + light.ATTR_BRIGHTNESS: 100 + }, data) async def test_intent_set_color(hass): """Test the set color intent.""" hass.states.async_set('light.hello_2', 'off', { - ATTR_SUPPORTED_FEATURES: light.SUPPORT_RGB_COLOR + ATTR_SUPPORTED_FEATURES: light.SUPPORT_COLOR }) hass.states.async_set('switch.hello', 'off') calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) @@ -364,7 +367,7 @@ async def test_intent_set_color_and_brightness(hass): """Test the set color intent.""" hass.states.async_set('light.hello_2', 'off', { ATTR_SUPPORTED_FEATURES: ( - light.SUPPORT_RGB_COLOR | light.SUPPORT_BRIGHTNESS) + light.SUPPORT_COLOR | light.SUPPORT_BRIGHTNESS) }) hass.states.async_set('switch.hello', 'off') calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 6c56564df69..8b51adb2187 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -140,14 +140,16 @@ light: """ import unittest from unittest import mock +from unittest.mock import patch from homeassistant.setup import setup_component from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) import homeassistant.components.light as light +import homeassistant.core as ha from tests.common import ( assert_setup_component, get_test_home_assistant, mock_mqtt_component, - fire_mqtt_message) + fire_mqtt_message, mock_coro) class TestLightMQTT(unittest.TestCase): @@ -250,12 +252,12 @@ class TestLightMQTT(unittest.TestCase): state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual((255, 255, 255), state.attributes.get('rgb_color')) self.assertEqual(255, state.attributes.get('brightness')) self.assertEqual(150, state.attributes.get('color_temp')) self.assertEqual('none', state.attributes.get('effect')) self.assertEqual(255, state.attributes.get('white_value')) - self.assertEqual([1, 1], state.attributes.get('xy_color')) + self.assertEqual((0.323, 0.329), state.attributes.get('xy_color')) fire_mqtt_message(self.hass, 'test_light_rgb/status', '0') self.hass.block_till_done() @@ -303,7 +305,7 @@ class TestLightMQTT(unittest.TestCase): self.hass.block_till_done() light_state = self.hass.states.get('light.test') - self.assertEqual([125, 125, 125], + self.assertEqual((255, 255, 255), light_state.attributes.get('rgb_color')) fire_mqtt_message(self.hass, 'test_light_rgb/xy/status', @@ -311,7 +313,7 @@ class TestLightMQTT(unittest.TestCase): self.hass.block_till_done() light_state = self.hass.states.get('light.test') - self.assertEqual([0.675, 0.322], + self.assertEqual((0.672, 0.324), light_state.attributes.get('xy_color')) def test_brightness_controlling_scale(self): @@ -458,11 +460,11 @@ class TestLightMQTT(unittest.TestCase): state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) self.assertEqual(50, state.attributes.get('brightness')) - self.assertEqual([1, 2, 3], state.attributes.get('rgb_color')) + self.assertEqual((0, 123, 255), state.attributes.get('rgb_color')) self.assertEqual(300, state.attributes.get('color_temp')) self.assertEqual('rainbow', state.attributes.get('effect')) self.assertEqual(75, state.attributes.get('white_value')) - self.assertEqual([0.123, 0.123], state.attributes.get('xy_color')) + self.assertEqual((0.14, 0.131), state.attributes.get('xy_color')) def test_sending_mqtt_commands_and_optimistic(self): \ # pylint: disable=invalid-name @@ -481,12 +483,23 @@ class TestLightMQTT(unittest.TestCase): 'payload_on': 'on', 'payload_off': 'off' }} - - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, config) + fake_state = ha.State('light.test', 'on', {'brightness': 95, + 'hs_color': [100, 100], + 'effect': 'random', + 'color_temp': 100, + 'white_value': 50}) + with patch('homeassistant.components.light.mqtt.async_get_last_state', + return_value=mock_coro(fake_state)): + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, config) state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) + self.assertEqual(STATE_ON, state.state) + self.assertEqual(95, state.attributes.get('brightness')) + self.assertEqual((100, 100), state.attributes.get('hs_color')) + self.assertEqual('random', state.attributes.get('effect')) + self.assertEqual(100, state.attributes.get('color_temp')) + self.assertEqual(50, state.attributes.get('white_value')) self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) light.turn_on(self.hass, 'light.test') @@ -516,18 +529,18 @@ class TestLightMQTT(unittest.TestCase): self.mock_publish.async_publish.assert_has_calls([ mock.call('test_light_rgb/set', 'on', 2, False), - mock.call('test_light_rgb/rgb/set', '75,75,75', 2, False), + mock.call('test_light_rgb/rgb/set', '50,50,50', 2, False), mock.call('test_light_rgb/brightness/set', 50, 2, False), mock.call('test_light_rgb/white_value/set', 80, 2, False), - mock.call('test_light_rgb/xy/set', '0.123,0.123', 2, False), + mock.call('test_light_rgb/xy/set', '0.323,0.329', 2, False), ], any_order=True) state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual((75, 75, 75), state.attributes['rgb_color']) + self.assertEqual((255, 255, 255), state.attributes['rgb_color']) self.assertEqual(50, state.attributes['brightness']) self.assertEqual(80, state.attributes['white_value']) - self.assertEqual((0.123, 0.123), state.attributes['xy_color']) + self.assertEqual((0.323, 0.329), state.attributes['xy_color']) def test_sending_mqtt_rgb_command_with_template(self): """Test the sending of RGB command with template.""" @@ -554,12 +567,12 @@ class TestLightMQTT(unittest.TestCase): self.mock_publish.async_publish.assert_has_calls([ mock.call('test_light_rgb/set', 'on', 0, False), - mock.call('test_light_rgb/rgb/set', '#ff8040', 0, False), + mock.call('test_light_rgb/rgb/set', '#ff803f', 0, False), ], any_order=True) state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual((255, 128, 64), state.attributes['rgb_color']) + self.assertEqual((255, 128, 63), state.attributes['rgb_color']) def test_show_brightness_if_only_command_topic(self): """Test the brightness if only a command topic is present.""" @@ -679,7 +692,7 @@ class TestLightMQTT(unittest.TestCase): state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual([1, 1], state.attributes.get('xy_color')) + self.assertEqual((0.323, 0.329), state.attributes.get('xy_color')) def test_on_command_first(self): """Test on command being sent before brightness.""" @@ -799,7 +812,7 @@ class TestLightMQTT(unittest.TestCase): self.hass.block_till_done() self.mock_publish.async_publish.assert_has_calls([ - mock.call('test_light/rgb', '75,75,75', 0, False), + mock.call('test_light/rgb', '50,50,50', 0, False), mock.call('test_light/bright', 50, 0, False) ], any_order=True) diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index ba306a81a34..275fb42ede9 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -90,15 +90,17 @@ light: import json import unittest +from unittest.mock import patch from homeassistant.setup import setup_component from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES) import homeassistant.components.light as light +import homeassistant.core as ha from tests.common import ( get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, - assert_setup_component) + assert_setup_component, mock_coro) class TestLightMQTTJSON(unittest.TestCase): @@ -146,6 +148,7 @@ class TestLightMQTTJSON(unittest.TestCase): self.assertIsNone(state.attributes.get('effect')) self.assertIsNone(state.attributes.get('white_value')) self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('hs_color')) fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"ON"}') self.hass.block_till_done() @@ -158,6 +161,7 @@ class TestLightMQTTJSON(unittest.TestCase): self.assertIsNone(state.attributes.get('effect')) self.assertIsNone(state.attributes.get('white_value')) self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('hs_color')) def test_controlling_state_via_topic(self): \ # pylint: disable=invalid-name @@ -174,26 +178,27 @@ class TestLightMQTTJSON(unittest.TestCase): 'rgb': True, 'white_value': True, 'xy': True, + 'hs': True, 'qos': '0' } }) state = self.hass.states.get('light.test') self.assertEqual(STATE_OFF, state.state) - self.assertEqual(255, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertEqual(191, state.attributes.get(ATTR_SUPPORTED_FEATURES)) self.assertIsNone(state.attributes.get('rgb_color')) self.assertIsNone(state.attributes.get('brightness')) self.assertIsNone(state.attributes.get('color_temp')) self.assertIsNone(state.attributes.get('effect')) self.assertIsNone(state.attributes.get('white_value')) self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('hs_color')) self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) # Turn on the light, full white fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"ON",' - '"color":{"r":255,"g":255,"b":255,' - '"x":0.123,"y":0.123},' + '"color":{"r":255,"g":255,"b":255},' '"brightness":255,' '"color_temp":155,' '"effect":"colorloop",' @@ -202,12 +207,13 @@ class TestLightMQTTJSON(unittest.TestCase): state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual((255, 255, 255), state.attributes.get('rgb_color')) self.assertEqual(255, state.attributes.get('brightness')) self.assertEqual(155, state.attributes.get('color_temp')) self.assertEqual('colorloop', state.attributes.get('effect')) self.assertEqual(150, state.attributes.get('white_value')) - self.assertEqual([0.123, 0.123], state.attributes.get('xy_color')) + self.assertEqual((0.323, 0.329), state.attributes.get('xy_color')) + self.assertEqual((0.0, 0.0), state.attributes.get('hs_color')) # Turn the light off fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"OFF"}') @@ -232,7 +238,7 @@ class TestLightMQTTJSON(unittest.TestCase): self.hass.block_till_done() light_state = self.hass.states.get('light.test') - self.assertEqual([125, 125, 125], + self.assertEqual((255, 255, 255), light_state.attributes.get('rgb_color')) fire_mqtt_message(self.hass, 'test_light_rgb', @@ -241,9 +247,18 @@ class TestLightMQTTJSON(unittest.TestCase): self.hass.block_till_done() light_state = self.hass.states.get('light.test') - self.assertEqual([0.135, 0.135], + self.assertEqual((0.141, 0.14), light_state.attributes.get('xy_color')) + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color":{"h":180,"s":50}}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual((180.0, 50.0), + light_state.attributes.get('hs_color')) + fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"ON",' '"color_temp":155}') @@ -271,22 +286,36 @@ class TestLightMQTTJSON(unittest.TestCase): def test_sending_mqtt_commands_and_optimistic(self): \ # pylint: disable=invalid-name """Test the sending of command in optimistic mode.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'brightness': True, - 'color_temp': True, - 'effect': True, - 'rgb': True, - 'white_value': True, - 'qos': 2 - } - }) + fake_state = ha.State('light.test', 'on', {'brightness': 95, + 'hs_color': [100, 100], + 'effect': 'random', + 'color_temp': 100, + 'white_value': 50}) + + with patch('homeassistant.components.light.mqtt_json' + '.async_get_last_state', + return_value=mock_coro(fake_state)): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'brightness': True, + 'color_temp': True, + 'effect': True, + 'rgb': True, + 'white_value': True, + 'qos': 2 + } + }) state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) + self.assertEqual(STATE_ON, state.state) + self.assertEqual(95, state.attributes.get('brightness')) + self.assertEqual((100, 100), state.attributes.get('hs_color')) + self.assertEqual('random', state.attributes.get('effect')) + self.assertEqual(100, state.attributes.get('color_temp')) + self.assertEqual(50, state.attributes.get('white_value')) self.assertEqual(191, state.attributes.get(ATTR_SUPPORTED_FEATURES)) self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) @@ -335,6 +364,55 @@ class TestLightMQTTJSON(unittest.TestCase): self.assertEqual('colorloop', state.attributes['effect']) self.assertEqual(170, state.attributes['white_value']) + # Test a color command + light.turn_on(self.hass, 'light.test', + brightness=50, hs_color=(125, 100)) + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(2, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) + # Get the sent message + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[1][1][1]) + self.assertEqual(50, message_json["brightness"]) + self.assertEqual({ + 'r': 0, + 'g': 50, + 'b': 4, + }, message_json["color"]) + self.assertEqual("ON", message_json["state"]) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(50, state.attributes['brightness']) + self.assertEqual((125, 100), state.attributes['hs_color']) + + def test_sending_hs_color(self): + """Test light.turn_on with hs color sends hs color parameters.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'hs': True, + } + }) + + light.turn_on(self.hass, 'light.test', hs_color=(180.0, 50.0)) + self.hass.block_till_done() + + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) + self.assertEqual("ON", message_json["state"]) + self.assertEqual({ + 'h': 180.0, + 's': 50.0, + }, message_json["color"]) + def test_flash_short_and_long(self): \ # pylint: disable=invalid-name """Test for flash length being sent when included.""" @@ -503,7 +581,7 @@ class TestLightMQTTJSON(unittest.TestCase): state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual((255, 255, 255), state.attributes.get('rgb_color')) self.assertEqual(255, state.attributes.get('brightness')) self.assertEqual(255, state.attributes.get('white_value')) @@ -516,7 +594,7 @@ class TestLightMQTTJSON(unittest.TestCase): # Color should not have changed state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual((255, 255, 255), state.attributes.get('rgb_color')) # Bad brightness values fire_mqtt_message(self.hass, 'test_light_rgb', diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 5a01aa15fa2..1440a73f98e 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -27,14 +27,16 @@ If your light doesn't support white value feature, omit `white_value_template`. If your light doesn't support RGB feature, omit `(red|green|blue)_template`. """ import unittest +from unittest.mock import patch from homeassistant.setup import setup_component from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) import homeassistant.components.light as light +import homeassistant.core as ha from tests.common import ( get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, - assert_setup_component) + assert_setup_component, mock_coro) class TestLightMQTTTemplate(unittest.TestCase): @@ -151,7 +153,7 @@ class TestLightMQTTTemplate(unittest.TestCase): state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 128, 64], state.attributes.get('rgb_color')) + self.assertEqual((255, 128, 63), state.attributes.get('rgb_color')) self.assertEqual(255, state.attributes.get('brightness')) self.assertEqual(145, state.attributes.get('color_temp')) self.assertEqual(123, state.attributes.get('white_value')) @@ -185,7 +187,8 @@ class TestLightMQTTTemplate(unittest.TestCase): self.hass.block_till_done() light_state = self.hass.states.get('light.test') - self.assertEqual([41, 42, 43], light_state.attributes.get('rgb_color')) + self.assertEqual((243, 249, 255), + light_state.attributes.get('rgb_color')) # change the white value fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,134') @@ -206,26 +209,40 @@ class TestLightMQTTTemplate(unittest.TestCase): def test_optimistic(self): \ # pylint: disable=invalid-name """Test optimistic mode.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,' - '{{ brightness|d }},' - '{{ color_temp|d }},' - '{{ white_value|d }},' - '{{ red|d }}-' - '{{ green|d }}-' - '{{ blue|d }}', - 'command_off_template': 'off', - 'qos': 2 - } - }) + fake_state = ha.State('light.test', 'on', {'brightness': 95, + 'hs_color': [100, 100], + 'effect': 'random', + 'color_temp': 100, + 'white_value': 50}) + + with patch('homeassistant.components.light.mqtt_template' + '.async_get_last_state', + return_value=mock_coro(fake_state)): + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ color_temp|d }},' + '{{ white_value|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }}', + 'command_off_template': 'off', + 'qos': 2 + } + }) state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) + self.assertEqual(STATE_ON, state.state) + self.assertEqual(95, state.attributes.get('brightness')) + self.assertEqual((100, 100), state.attributes.get('hs_color')) + self.assertEqual('random', state.attributes.get('effect')) + self.assertEqual(100, state.attributes.get('color_temp')) + self.assertEqual(50, state.attributes.get('white_value')) self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) # turn on the light @@ -254,7 +271,7 @@ class TestLightMQTTTemplate(unittest.TestCase): self.hass.block_till_done() self.mock_publish.async_publish.assert_called_once_with( - 'test_light_rgb/set', 'on,50,,,75-75-75', 2, False) + 'test_light_rgb/set', 'on,50,,,50-50-50', 2, False) self.mock_publish.async_publish.reset_mock() # turn on the light with color temp and white val @@ -267,7 +284,7 @@ class TestLightMQTTTemplate(unittest.TestCase): # check the state state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual((75, 75, 75), state.attributes['rgb_color']) + self.assertEqual((255, 255, 255), state.attributes['rgb_color']) self.assertEqual(50, state.attributes['brightness']) self.assertEqual(200, state.attributes['color_temp']) self.assertEqual(139, state.attributes['white_value']) @@ -387,7 +404,7 @@ class TestLightMQTTTemplate(unittest.TestCase): self.assertEqual(STATE_ON, state.state) self.assertEqual(255, state.attributes.get('brightness')) self.assertEqual(215, state.attributes.get('color_temp')) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual((255, 255, 255), state.attributes.get('rgb_color')) self.assertEqual(222, state.attributes.get('white_value')) self.assertEqual('rainbow', state.attributes.get('effect')) @@ -421,7 +438,7 @@ class TestLightMQTTTemplate(unittest.TestCase): # color should not have changed state = self.hass.states.get('light.test') - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual((255, 255, 255), state.attributes.get('rgb_color')) # bad white value values fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,off,255-255-255') diff --git a/tests/components/light/test_template.py b/tests/components/light/test_template.py index 2d45ad1bf94..962760672f1 100644 --- a/tests/components/light/test_template.py +++ b/tests/components/light/test_template.py @@ -36,7 +36,7 @@ class TestTemplateLight: self.hass.stop() def test_template_state_text(self): - """"Test the state text of a template.""" + """Test the state text of a template.""" with assert_setup_component(1, 'light'): assert setup.setup_component(self.hass, 'light', { 'light': { diff --git a/tests/components/light/test_zwave.py b/tests/components/light/test_zwave.py index b925b74a7f0..4966b161360 100644 --- a/tests/components/light/test_zwave.py +++ b/tests/components/light/test_zwave.py @@ -4,9 +4,9 @@ from unittest.mock import patch, MagicMock import homeassistant.components.zwave from homeassistant.components.zwave import const from homeassistant.components.light import ( - zwave, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION, - SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION, SUPPORT_RGB_COLOR, - SUPPORT_COLOR_TEMP) + zwave, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION, SUPPORT_COLOR, ATTR_WHITE_VALUE, + SUPPORT_COLOR_TEMP, SUPPORT_WHITE_VALUE) from tests.mock.zwave import ( MockNode, MockValue, MockEntityValues, value_changed) @@ -42,7 +42,7 @@ def test_get_device_detects_colorlight(mock_openzwave): device = zwave.get_device(node=node, values=values, node_config={}) assert isinstance(device, zwave.ZwaveColorLight) - assert device.supported_features == SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR + assert device.supported_features == SUPPORT_BRIGHTNESS | SUPPORT_COLOR def test_get_device_detects_zw098(mock_openzwave): @@ -54,7 +54,23 @@ def test_get_device_detects_zw098(mock_openzwave): device = zwave.get_device(node=node, values=values, node_config={}) assert isinstance(device, zwave.ZwaveColorLight) assert device.supported_features == ( - SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR | SUPPORT_COLOR_TEMP) + SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP) + + +def test_get_device_detects_rgbw_light(mock_openzwave): + """Test get_device returns a color light.""" + node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR]) + value = MockValue(data=0, node=node) + color = MockValue(data='#0000000000', node=node) + color_channels = MockValue(data=0x1d, node=node) + values = MockLightValues( + primary=value, color=color, color_channels=color_channels) + + device = zwave.get_device(node=node, values=values, node_config={}) + device.value_added() + assert isinstance(device, zwave.ZwaveColorLight) + assert device.supported_features == ( + SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_WHITE_VALUE) def test_dimmer_turn_on(mock_openzwave): @@ -203,7 +219,7 @@ def test_dimmer_refresh_value(mock_openzwave): assert device.brightness == 118 -def test_set_rgb_color(mock_openzwave): +def test_set_hs_color(mock_openzwave): """Test setting zwave light color.""" node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR]) value = MockValue(data=0, node=node) @@ -216,12 +232,12 @@ def test_set_rgb_color(mock_openzwave): assert color.data == '#0000000000' - device.turn_on(**{ATTR_RGB_COLOR: (200, 150, 100)}) + device.turn_on(**{ATTR_HS_COLOR: (30, 50)}) - assert color.data == '#c896640000' + assert color.data == '#ffbf7f0000' -def test_set_rgbw_color(mock_openzwave): +def test_set_white_value(mock_openzwave): """Test setting zwave light color.""" node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR]) value = MockValue(data=0, node=node) @@ -234,9 +250,9 @@ def test_set_rgbw_color(mock_openzwave): assert color.data == '#0000000000' - device.turn_on(**{ATTR_RGB_COLOR: (200, 150, 100)}) + device.turn_on(**{ATTR_WHITE_VALUE: 200}) - assert color.data == '#c86400c800' + assert color.data == '#ffffffc800' def test_zw098_set_color_temp(mock_openzwave): @@ -273,7 +289,7 @@ def test_rgb_not_supported(mock_openzwave): color_channels=color_channels) device = zwave.get_device(node=node, values=values, node_config={}) - assert device.rgb_color is None + assert device.hs_color is None def test_no_color_value(mock_openzwave): @@ -283,7 +299,7 @@ def test_no_color_value(mock_openzwave): values = MockLightValues(primary=value) device = zwave.get_device(node=node, values=values, node_config={}) - assert device.rgb_color is None + assert device.hs_color is None def test_no_color_channels_value(mock_openzwave): @@ -294,7 +310,7 @@ def test_no_color_channels_value(mock_openzwave): values = MockLightValues(primary=value, color=color) device = zwave.get_device(node=node, values=values, node_config={}) - assert device.rgb_color is None + assert device.hs_color is None def test_rgb_value_changed(mock_openzwave): @@ -308,12 +324,12 @@ def test_rgb_value_changed(mock_openzwave): color_channels=color_channels) device = zwave.get_device(node=node, values=values, node_config={}) - assert device.rgb_color == [0, 0, 0] + assert device.hs_color == (0, 0) - color.data = '#c896640000' + color.data = '#ffbf800000' value_changed(color) - assert device.rgb_color == [200, 150, 100] + assert device.hs_color == (29.764, 49.804) def test_rgbww_value_changed(mock_openzwave): @@ -327,12 +343,14 @@ def test_rgbww_value_changed(mock_openzwave): color_channels=color_channels) device = zwave.get_device(node=node, values=values, node_config={}) - assert device.rgb_color == [0, 0, 0] + assert device.hs_color == (0, 0) + assert device.white_value == 0 color.data = '#c86400c800' value_changed(color) - assert device.rgb_color == [200, 150, 100] + assert device.hs_color == (30, 100) + assert device.white_value == 200 def test_rgbcw_value_changed(mock_openzwave): @@ -346,12 +364,14 @@ def test_rgbcw_value_changed(mock_openzwave): color_channels=color_channels) device = zwave.get_device(node=node, values=values, node_config={}) - assert device.rgb_color == [0, 0, 0] + assert device.hs_color == (0, 0) + assert device.white_value == 0 color.data = '#c86400c800' value_changed(color) - assert device.rgb_color == [200, 150, 100] + assert device.hs_color == (30, 100) + assert device.white_value == 200 def test_ct_value_changed(mock_openzwave): diff --git a/tests/components/lock/test_demo.py b/tests/components/lock/test_demo.py index 12007d2b8ad..1d774248f35 100644 --- a/tests/components/lock/test_demo.py +++ b/tests/components/lock/test_demo.py @@ -4,11 +4,10 @@ import unittest from homeassistant.setup import setup_component from homeassistant.components import lock -from tests.common import get_test_home_assistant - - +from tests.common import get_test_home_assistant, mock_service FRONT = 'lock.front_door' KITCHEN = 'lock.kitchen_door' +OPENABLE_LOCK = 'lock.openable_lock' class TestLockDemo(unittest.TestCase): @@ -48,3 +47,10 @@ class TestLockDemo(unittest.TestCase): self.hass.block_till_done() self.assertFalse(lock.is_locked(self.hass, FRONT)) + + def test_opening(self): + """Test the opening of a lock.""" + calls = mock_service(self.hass, lock.DOMAIN, lock.SERVICE_OPEN) + lock.open_lock(self.hass, OPENABLE_LOCK) + self.hass.block_till_done() + self.assertEqual(1, len(calls)) diff --git a/tests/components/mailbox/test_init.py b/tests/components/mailbox/test_init.py index c9267fa8e8e..3377fcefcf5 100644 --- a/tests/components/mailbox/test_init.py +++ b/tests/components/mailbox/test_init.py @@ -9,7 +9,7 @@ import homeassistant.components.mailbox as mailbox @pytest.fixture -def mock_http_client(hass, test_client): +def mock_http_client(hass, aiohttp_client): """Start the Hass HTTP component.""" config = { mailbox.DOMAIN: { @@ -18,7 +18,7 @@ def mock_http_client(hass, test_client): } hass.loop.run_until_complete( async_setup_component(hass, mailbox.DOMAIN, config)) - return hass.loop.run_until_complete(test_client(hass.http.app)) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine diff --git a/tests/components/media_player/test_blackbird.py b/tests/components/media_player/test_blackbird.py new file mode 100644 index 00000000000..7c85775949c --- /dev/null +++ b/tests/components/media_player/test_blackbird.py @@ -0,0 +1,321 @@ +"""The tests for the Monoprice Blackbird media player platform.""" +import unittest +from unittest import mock +import voluptuous as vol + +from collections import defaultdict +from homeassistant.components.media_player import ( + DOMAIN, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_SELECT_SOURCE) +from homeassistant.const import STATE_ON, STATE_OFF + +import tests.common +from homeassistant.components.media_player.blackbird import ( + DATA_BLACKBIRD, PLATFORM_SCHEMA, SERVICE_SETALLZONES, setup_platform) + + +class AttrDict(dict): + """Helper class for mocking attributes.""" + + def __setattr__(self, name, value): + """Set attribute.""" + self[name] = value + + def __getattr__(self, item): + """Get attribute.""" + return self[item] + + +class MockBlackbird(object): + """Mock for pyblackbird object.""" + + def __init__(self): + """Init mock object.""" + self.zones = defaultdict(lambda: AttrDict(power=True, + av=1)) + + def zone_status(self, zone_id): + """Get zone status.""" + status = self.zones[zone_id] + status.zone = zone_id + return AttrDict(status) + + def set_zone_source(self, zone_id, source_idx): + """Set source for zone.""" + self.zones[zone_id].av = source_idx + + def set_zone_power(self, zone_id, power): + """Turn zone on/off.""" + self.zones[zone_id].power = power + + def set_all_zone_source(self, source_idx): + """Set source for all zones.""" + self.zones[3].av = source_idx + + +class TestBlackbirdSchema(unittest.TestCase): + """Test Blackbird schema.""" + + def test_valid_serial_schema(self): + """Test valid schema.""" + valid_schema = { + 'platform': 'blackbird', + 'port': '/dev/ttyUSB0', + 'zones': {1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + 5: {'name': 'a'}, + 6: {'name': 'a'}, + 7: {'name': 'a'}, + 8: {'name': 'a'}, + }, + 'sources': { + 1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + 5: {'name': 'a'}, + 6: {'name': 'a'}, + 7: {'name': 'a'}, + 8: {'name': 'a'}, + } + } + PLATFORM_SCHEMA(valid_schema) + + def test_valid_socket_schema(self): + """Test valid schema.""" + valid_schema = { + 'platform': 'blackbird', + 'host': '192.168.1.50', + 'zones': {1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + 5: {'name': 'a'}, + }, + 'sources': { + 1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + } + } + PLATFORM_SCHEMA(valid_schema) + + def test_invalid_schemas(self): + """Test invalid schemas.""" + schemas = ( + {}, # Empty + None, # None + # Port and host used concurrently + { + 'platform': 'blackbird', + 'port': '/dev/ttyUSB0', + 'host': '192.168.1.50', + 'name': 'Name', + 'zones': {1: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + # Port or host missing + { + 'platform': 'blackbird', + 'name': 'Name', + 'zones': {1: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + # Invalid zone number + { + 'platform': 'blackbird', + 'port': '/dev/ttyUSB0', + 'name': 'Name', + 'zones': {11: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + # Invalid source number + { + 'platform': 'blackbird', + 'port': '/dev/ttyUSB0', + 'name': 'Name', + 'zones': {1: {'name': 'a'}}, + 'sources': {9: {'name': 'b'}}, + }, + # Zone missing name + { + 'platform': 'blackbird', + 'port': '/dev/ttyUSB0', + 'name': 'Name', + 'zones': {1: {}}, + 'sources': {1: {'name': 'b'}}, + }, + # Source missing name + { + 'platform': 'blackbird', + 'port': '/dev/ttyUSB0', + 'name': 'Name', + 'zones': {1: {'name': 'a'}}, + 'sources': {1: {}}, + }, + ) + for value in schemas: + with self.assertRaises(vol.MultipleInvalid): + PLATFORM_SCHEMA(value) + + +class TestBlackbirdMediaPlayer(unittest.TestCase): + """Test the media_player module.""" + + def setUp(self): + """Set up the test case.""" + self.blackbird = MockBlackbird() + self.hass = tests.common.get_test_home_assistant() + self.hass.start() + # Note, source dictionary is unsorted! + with mock.patch('pyblackbird.get_blackbird', + new=lambda *a: self.blackbird): + setup_platform(self.hass, { + 'platform': 'blackbird', + 'port': '/dev/ttyUSB0', + 'zones': {3: {'name': 'Zone name'}}, + 'sources': {1: {'name': 'one'}, + 3: {'name': 'three'}, + 2: {'name': 'two'}}, + }, lambda *args, **kwargs: None, {}) + self.hass.block_till_done() + self.media_player = self.hass.data[DATA_BLACKBIRD]['/dev/ttyUSB0-3'] + self.media_player.hass = self.hass + self.media_player.entity_id = 'media_player.zone_3' + + def tearDown(self): + """Tear down the test case.""" + self.hass.stop() + + def test_setup_platform(self, *args): + """Test setting up platform.""" + # One service must be registered + self.assertTrue(self.hass.services.has_service(DOMAIN, + SERVICE_SETALLZONES)) + self.assertEqual(len(self.hass.data[DATA_BLACKBIRD]), 1) + self.assertEqual(self.hass.data[DATA_BLACKBIRD]['/dev/ttyUSB0-3'].name, + 'Zone name') + + def test_setallzones_service_call_with_entity_id(self): + """Test set all zone source service call with entity id.""" + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual('one', self.media_player.source) + + # Call set all zones service + self.hass.services.call(DOMAIN, SERVICE_SETALLZONES, + {'entity_id': 'media_player.zone_3', + 'source': 'three'}, + blocking=True) + + # Check that source was changed + self.assertEqual(3, self.blackbird.zones[3].av) + self.media_player.update() + self.assertEqual('three', self.media_player.source) + + def test_setallzones_service_call_without_entity_id(self): + """Test set all zone source service call without entity id.""" + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual('one', self.media_player.source) + + # Call set all zones service + self.hass.services.call(DOMAIN, SERVICE_SETALLZONES, + {'source': 'three'}, blocking=True) + + # Check that source was changed + self.assertEqual(3, self.blackbird.zones[3].av) + self.media_player.update() + self.assertEqual('three', self.media_player.source) + + def test_update(self): + """Test updating values from blackbird.""" + self.assertIsNone(self.media_player.state) + self.assertIsNone(self.media_player.source) + + self.media_player.update() + + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual('one', self.media_player.source) + + def test_name(self): + """Test name property.""" + self.assertEqual('Zone name', self.media_player.name) + + def test_state(self): + """Test state property.""" + self.assertIsNone(self.media_player.state) + + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + self.blackbird.zones[3].power = False + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) + + def test_supported_features(self): + """Test supported features property.""" + self.assertEqual(SUPPORT_TURN_ON | SUPPORT_TURN_OFF | + SUPPORT_SELECT_SOURCE, + self.media_player.supported_features) + + def test_source(self): + """Test source property.""" + self.assertIsNone(self.media_player.source) + self.media_player.update() + self.assertEqual('one', self.media_player.source) + + def test_media_title(self): + """Test media title property.""" + self.assertIsNone(self.media_player.media_title) + self.media_player.update() + self.assertEqual('one', self.media_player.media_title) + + def test_source_list(self): + """Test source list property.""" + # Note, the list is sorted! + self.assertEqual(['one', 'two', 'three'], + self.media_player.source_list) + + def test_select_source(self): + """Test source selection methods.""" + self.media_player.update() + + self.assertEqual('one', self.media_player.source) + + self.media_player.select_source('two') + self.assertEqual(2, self.blackbird.zones[3].av) + self.media_player.update() + self.assertEqual('two', self.media_player.source) + + # Trying to set unknown source. + self.media_player.select_source('no name') + self.assertEqual(2, self.blackbird.zones[3].av) + self.media_player.update() + self.assertEqual('two', self.media_player.source) + + def test_turn_on(self): + """Testing turning on the zone.""" + self.blackbird.zones[3].power = False + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) + + self.media_player.turn_on() + self.assertTrue(self.blackbird.zones[3].power) + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + def test_turn_off(self): + """Testing turning off the zone.""" + self.blackbird.zones[3].power = True + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + self.media_player.turn_off() + self.assertFalse(self.blackbird.zones[3].power) + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index 2075b4cf6e6..41cf6749b71 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -5,12 +5,17 @@ from typing import Optional from unittest.mock import patch, MagicMock, Mock from uuid import UUID +import attr import pytest from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.components.media_player.cast import ChromecastInfo from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import async_dispatcher_connect, \ + async_dispatcher_send from homeassistant.components.media_player import cast +from homeassistant.setup import async_setup_component @pytest.fixture(autouse=True) @@ -26,57 +31,74 @@ def cast_mock(): FakeUUID = UUID('57355bce-9364-4aa6-ac1e-eb849dccf9e2') -def get_fake_chromecast(host='192.168.178.42', port=8009, - uuid: Optional[UUID] = FakeUUID): +def get_fake_chromecast(info: ChromecastInfo): """Generate a Fake Chromecast object with the specified arguments.""" - return MagicMock(host=host, port=port, uuid=uuid) + mock = MagicMock(host=info.host, port=info.port, uuid=info.uuid) + mock.media_controller.status = None + return mock -@asyncio.coroutine -def async_setup_cast(hass, config=None, discovery_info=None): +def get_fake_chromecast_info(host='192.168.178.42', port=8009, + uuid: Optional[UUID] = FakeUUID): + """Generate a Fake ChromecastInfo with the specified arguments.""" + return ChromecastInfo(host=host, port=port, uuid=uuid, + friendly_name="Speaker") + + +async def async_setup_cast(hass, config=None, discovery_info=None): """Helper to setup the cast platform.""" if config is None: config = {} add_devices = Mock() - yield from cast.async_setup_platform(hass, config, add_devices, - discovery_info=discovery_info) - yield from hass.async_block_till_done() + await cast.async_setup_platform(hass, config, add_devices, + discovery_info=discovery_info) + await hass.async_block_till_done() return add_devices -@asyncio.coroutine -def async_setup_cast_internal_discovery(hass, config=None, - discovery_info=None, - no_from_host_patch=False): +async def async_setup_cast_internal_discovery(hass, config=None, + discovery_info=None): """Setup the cast platform and the discovery.""" listener = MagicMock(services={}) with patch('pychromecast.start_discovery', return_value=(listener, None)) as start_discovery: - add_devices = yield from async_setup_cast(hass, config, discovery_info) - yield from hass.async_block_till_done() - yield from hass.async_block_till_done() + add_devices = await async_setup_cast(hass, config, discovery_info) + await hass.async_block_till_done() + await hass.async_block_till_done() assert start_discovery.call_count == 1 discovery_callback = start_discovery.call_args[0][0] - def discover_chromecast(service_name, chromecast): + def discover_chromecast(service_name: str, info: ChromecastInfo) -> None: """Discover a chromecast device.""" - listener.services[service_name] = ( - chromecast.host, chromecast.port, chromecast.uuid, None, None) - if no_from_host_patch: - discovery_callback(service_name) - else: - with patch('pychromecast._get_chromecast_from_host', - return_value=chromecast): - discovery_callback(service_name) + listener.services[service_name] = attr.astuple(info) + discovery_callback(service_name) return discover_chromecast, add_devices +async def async_setup_media_player_cast(hass: HomeAssistantType, + info: ChromecastInfo): + """Setup the cast platform with async_setup_component.""" + chromecast = get_fake_chromecast(info) + + cast.CastStatusListener = MagicMock() + + with patch('pychromecast._get_chromecast_from_host', + return_value=chromecast) as get_chromecast: + await async_setup_component(hass, 'media_player', { + 'media_player': {'platform': 'cast', 'host': info.host}}) + await hass.async_block_till_done() + assert get_chromecast.call_count == 1 + assert cast.CastStatusListener.call_count == 1 + entity = cast.CastStatusListener.call_args[0][0] + return chromecast, entity + + @asyncio.coroutine def test_start_discovery_called_once(hass): """Test pychromecast.start_discovery called exactly once.""" @@ -95,11 +117,13 @@ def test_stop_discovery_called_on_stop(hass): """Test pychromecast.stop_discovery called on shutdown.""" with patch('pychromecast.start_discovery', return_value=(None, 'the-browser')) as start_discovery: - yield from async_setup_cast(hass) + # start_discovery should be called with empty config + yield from async_setup_cast(hass, {}) assert start_discovery.call_count == 1 with patch('pychromecast.stop_discovery') as stop_discovery: + # stop discovery should be called on shutdown hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) yield from hass.async_block_till_done() @@ -107,145 +131,231 @@ def test_stop_discovery_called_on_stop(hass): with patch('pychromecast.start_discovery', return_value=(None, 'the-browser')) as start_discovery: + # start_discovery should be called again on re-startup yield from async_setup_cast(hass) assert start_discovery.call_count == 1 -@asyncio.coroutine -def test_internal_discovery_callback_only_generates_once(hass): - """Test _get_chromecast_from_host only called once per device.""" - discover_cast, _ = yield from async_setup_cast_internal_discovery( - hass, no_from_host_patch=True) - chromecast = get_fake_chromecast() +async def test_internal_discovery_callback_only_generates_once(hass): + """Test discovery only called once per device.""" + discover_cast, _ = await async_setup_cast_internal_discovery(hass) + info = get_fake_chromecast_info() - with patch('pychromecast._get_chromecast_from_host', - return_value=chromecast) as gen_chromecast: - discover_cast('the-service', chromecast) - mdns = (chromecast.host, chromecast.port, chromecast.uuid, None, None) - gen_chromecast.assert_called_once_with(mdns, blocking=True) + signal = MagicMock() + async_dispatcher_connect(hass, 'cast_discovered', signal) - discover_cast('the-service', chromecast) - gen_chromecast.reset_mock() - assert gen_chromecast.call_count == 0 - - -@asyncio.coroutine -def test_internal_discovery_callback_calls_dispatcher(hass): - """Test internal discovery calls dispatcher.""" - discover_cast, _ = yield from async_setup_cast_internal_discovery(hass) - chromecast = get_fake_chromecast() - - with patch('pychromecast._get_chromecast_from_host', - return_value=chromecast): - signal = MagicMock() - - async_dispatcher_connect(hass, 'cast_discovered', signal) - discover_cast('the-service', chromecast) - yield from hass.async_block_till_done() - - signal.assert_called_once_with(chromecast) - - -@asyncio.coroutine -def test_internal_discovery_callback_with_connection_error(hass): - """Test internal discovery not calling dispatcher on ConnectionError.""" - import pychromecast # imports mock pychromecast - - pychromecast.ChromecastConnectionError = IOError - - discover_cast, _ = yield from async_setup_cast_internal_discovery( - hass, no_from_host_patch=True) - chromecast = get_fake_chromecast() - - with patch('pychromecast._get_chromecast_from_host', - side_effect=pychromecast.ChromecastConnectionError): - signal = MagicMock() - - async_dispatcher_connect(hass, 'cast_discovered', signal) - discover_cast('the-service', chromecast) - yield from hass.async_block_till_done() + with patch('pychromecast.dial.get_device_status', return_value=None): + # discovering a cast device should call the dispatcher + discover_cast('the-service', info) + await hass.async_block_till_done() + discover = signal.mock_calls[0][1][0] + # attr's __eq__ somehow breaks here, use tuples instead + assert attr.astuple(discover) == attr.astuple(info) + signal.reset_mock() + # discovering it a second time shouldn't + discover_cast('the-service', info) + await hass.async_block_till_done() assert signal.call_count == 0 -def test_create_cast_device_without_uuid(hass): - """Test create a cast device without a UUID.""" - chromecast = get_fake_chromecast(uuid=None) - cast_device = cast._async_create_cast_device(hass, chromecast) - assert cast_device is not None - - -def test_create_cast_device_with_uuid(hass): - """Test create cast devices with UUID.""" - added_casts = hass.data[cast.ADDED_CAST_DEVICES_KEY] = {} - chromecast = get_fake_chromecast() - cast_device = cast._async_create_cast_device(hass, chromecast) - assert cast_device is not None - assert chromecast.uuid in added_casts - - with patch.object(cast_device, 'async_set_chromecast') as mock_set: - assert cast._async_create_cast_device(hass, chromecast) is None - assert mock_set.call_count == 0 - - chromecast = get_fake_chromecast(host='192.168.178.1') - assert cast._async_create_cast_device(hass, chromecast) is None - assert mock_set.call_count == 1 - mock_set.assert_called_once_with(chromecast) - - -@asyncio.coroutine -def test_normal_chromecast_not_starting_discovery(hass): - """Test cast platform not starting discovery when not required.""" +async def test_internal_discovery_callback_fill_out(hass): + """Test internal discovery automatically filling out information.""" import pychromecast # imports mock pychromecast pychromecast.ChromecastConnectionError = IOError - chromecast = get_fake_chromecast() + discover_cast, _ = await async_setup_cast_internal_discovery(hass) + info = get_fake_chromecast_info(uuid=None) + full_info = attr.evolve(info, model_name='google home', + friendly_name='Speaker', uuid=FakeUUID) - with patch('pychromecast.Chromecast', return_value=chromecast): - add_devices = yield from async_setup_cast(hass, {'host': 'host1'}) + with patch('pychromecast.dial.get_device_status', + return_value=full_info): + signal = MagicMock() + + async_dispatcher_connect(hass, 'cast_discovered', signal) + discover_cast('the-service', info) + await hass.async_block_till_done() + + # when called with incomplete info, it should use HTTP to get missing + discover = signal.mock_calls[0][1][0] + # attr's __eq__ somehow breaks here, use tuples instead + assert attr.astuple(discover) == attr.astuple(full_info) + + +async def test_create_cast_device_without_uuid(hass): + """Test create a cast device with no UUId should still create an entity.""" + info = get_fake_chromecast_info(uuid=None) + cast_device = cast._async_create_cast_device(hass, info) + assert cast_device is not None + + +async def test_create_cast_device_with_uuid(hass): + """Test create cast devices with UUID creates entities.""" + added_casts = hass.data[cast.ADDED_CAST_DEVICES_KEY] = set() + info = get_fake_chromecast_info() + + cast_device = cast._async_create_cast_device(hass, info) + assert cast_device is not None + assert info.uuid in added_casts + + # Sending second time should not create new entity + cast_device = cast._async_create_cast_device(hass, info) + assert cast_device is None + + +async def test_normal_chromecast_not_starting_discovery(hass): + """Test cast platform not starting discovery when not required.""" + # pylint: disable=no-member + with patch('homeassistant.components.media_player.cast.' + '_setup_internal_discovery') as setup_discovery: + # normal (non-group) chromecast shouldn't start discovery. + add_devices = await async_setup_cast(hass, {'host': 'host1'}) + await hass.async_block_till_done() assert add_devices.call_count == 1 + assert setup_discovery.call_count == 0 # Same entity twice - add_devices = yield from async_setup_cast(hass, {'host': 'host1'}) + add_devices = await async_setup_cast(hass, {'host': 'host1'}) + await hass.async_block_till_done() assert add_devices.call_count == 0 + assert setup_discovery.call_count == 0 - hass.data[cast.ADDED_CAST_DEVICES_KEY] = {} - add_devices = yield from async_setup_cast( + hass.data[cast.ADDED_CAST_DEVICES_KEY] = set() + add_devices = await async_setup_cast( hass, discovery_info={'host': 'host1', 'port': 8009}) + await hass.async_block_till_done() assert add_devices.call_count == 1 + assert setup_discovery.call_count == 0 - hass.data[cast.ADDED_CAST_DEVICES_KEY] = {} - add_devices = yield from async_setup_cast( + # group should start discovery. + hass.data[cast.ADDED_CAST_DEVICES_KEY] = set() + add_devices = await async_setup_cast( hass, discovery_info={'host': 'host1', 'port': 42}) + await hass.async_block_till_done() assert add_devices.call_count == 0 + assert setup_discovery.call_count == 1 - with patch('pychromecast.Chromecast', - side_effect=pychromecast.ChromecastConnectionError): + +async def test_normal_raises_platform_not_ready(hass): + """Test cast platform raises PlatformNotReady if HTTP dial fails.""" + with patch('pychromecast.dial.get_device_status', return_value=None): with pytest.raises(PlatformNotReady): - yield from async_setup_cast(hass, {'host': 'host3'}) + await async_setup_cast(hass, {'host': 'host1'}) -@asyncio.coroutine -def test_replay_past_chromecasts(hass): +async def test_replay_past_chromecasts(hass): """Test cast platform re-playing past chromecasts when adding new one.""" - cast_group1 = get_fake_chromecast(host='host1', port=42) - cast_group2 = get_fake_chromecast(host='host2', port=42, uuid=UUID( + cast_group1 = get_fake_chromecast_info(host='host1', port=42) + cast_group2 = get_fake_chromecast_info(host='host2', port=42, uuid=UUID( '9462202c-e747-4af5-a66b-7dce0e1ebc09')) - discover_cast, add_dev1 = yield from async_setup_cast_internal_discovery( + discover_cast, add_dev1 = await async_setup_cast_internal_discovery( hass, discovery_info={'host': 'host1', 'port': 42}) discover_cast('service2', cast_group2) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert add_dev1.call_count == 0 discover_cast('service1', cast_group1) - yield from hass.async_block_till_done() - yield from hass.async_block_till_done() # having jobs that add jobs + await hass.async_block_till_done() + await hass.async_block_till_done() # having tasks that add jobs assert add_dev1.call_count == 1 - add_dev2 = yield from async_setup_cast( + add_dev2 = await async_setup_cast( hass, discovery_info={'host': 'host2', 'port': 42}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert add_dev2.call_count == 1 + + +async def test_entity_media_states(hass: HomeAssistantType): + """Test various entity media states.""" + info = get_fake_chromecast_info() + full_info = attr.evolve(info, model_name='google home', + friendly_name='Speaker', uuid=FakeUUID) + + with patch('pychromecast.dial.get_device_status', + return_value=full_info): + chromecast, entity = await async_setup_media_player_cast(hass, info) + + state = hass.states.get('media_player.speaker') + assert state is not None + assert state.name == 'Speaker' + assert state.state == 'unknown' + assert entity.unique_id == full_info.uuid + + media_status = MagicMock(images=None) + media_status.player_is_playing = True + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.state == 'playing' + + entity.new_media_status(media_status) + media_status.player_is_playing = False + media_status.player_is_paused = True + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.state == 'paused' + + entity.new_media_status(media_status) + media_status.player_is_paused = False + media_status.player_is_idle = True + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.state == 'idle' + + media_status.player_is_idle = False + chromecast.is_idle = True + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.state == 'off' + + chromecast.is_idle = False + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.state == 'unknown' + + +async def test_switched_host(hass: HomeAssistantType): + """Test cast device listens for changed hosts and disconnects old cast.""" + info = get_fake_chromecast_info() + full_info = attr.evolve(info, model_name='google home', + friendly_name='Speaker', uuid=FakeUUID) + + with patch('pychromecast.dial.get_device_status', + return_value=full_info): + chromecast, _ = await async_setup_media_player_cast(hass, full_info) + + chromecast2 = get_fake_chromecast(info) + with patch('pychromecast._get_chromecast_from_host', + return_value=chromecast2) as get_chromecast: + async_dispatcher_send(hass, cast.SIGNAL_CAST_DISCOVERED, full_info) + await hass.async_block_till_done() + assert get_chromecast.call_count == 0 + + changed = attr.evolve(full_info, friendly_name='Speaker 2') + async_dispatcher_send(hass, cast.SIGNAL_CAST_DISCOVERED, changed) + await hass.async_block_till_done() + assert get_chromecast.call_count == 0 + + changed = attr.evolve(changed, host='host2') + async_dispatcher_send(hass, cast.SIGNAL_CAST_DISCOVERED, changed) + await hass.async_block_till_done() + assert get_chromecast.call_count == 1 + assert chromecast.disconnect.call_count == 1 + + +async def test_disconnect_on_stop(hass: HomeAssistantType): + """Test cast device disconnects socket on stop.""" + info = get_fake_chromecast_info() + + with patch('pychromecast.dial.get_device_status', return_value=info): + chromecast, _ = await async_setup_media_player_cast(hass, info) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert chromecast.disconnect.call_count == 1 diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py new file mode 100644 index 00000000000..5d632d4de0b --- /dev/null +++ b/tests/components/media_player/test_init.py @@ -0,0 +1,37 @@ +"""Test the base functions of the media player.""" +import base64 +from unittest.mock import patch + +from homeassistant.setup import async_setup_component +from homeassistant.components import websocket_api + +from tests.common import mock_coro + + +async def test_get_panels(hass, hass_ws_client): + """Test get_panels command.""" + await async_setup_component(hass, 'media_player', { + 'media_player': { + 'platform': 'demo' + } + }) + + client = await hass_ws_client(hass) + + with patch('homeassistant.components.media_player.MediaPlayerDevice.' + 'async_get_media_image', return_value=mock_coro( + (b'image', 'image/jpeg'))): + await client.send_json({ + 'id': 5, + 'type': 'media_player_thumbnail', + 'entity_id': 'media_player.bedroom', + }) + + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == websocket_api.TYPE_RESULT + assert msg['success'] + assert msg['result']['content_type'] == 'image/jpeg' + assert msg['result']['content'] == \ + base64.b64encode(b'image').decode('utf-8') diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index 3470c79ad64..7d0d675f66f 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -9,8 +9,7 @@ from soco import alarms from homeassistant.setup import setup_component from homeassistant.components.media_player import sonos, DOMAIN -from homeassistant.components.media_player.sonos import CONF_INTERFACE_ADDR, \ - CONF_ADVERTISE_ADDR +from homeassistant.components.media_player.sonos import CONF_INTERFACE_ADDR from homeassistant.const import CONF_HOSTS, CONF_PLATFORM from tests.common import get_test_home_assistant @@ -162,7 +161,7 @@ class TestSonosMediaPlayer(unittest.TestCase): 'host': '192.0.2.1' }) - devices = self.hass.data[sonos.DATA_SONOS].devices + devices = list(self.hass.data[sonos.DATA_SONOS].devices) self.assertEqual(len(devices), 1) self.assertEqual(devices[0].name, 'Kitchen') @@ -185,27 +184,6 @@ class TestSonosMediaPlayer(unittest.TestCase): self.assertEqual(len(self.hass.data[sonos.DATA_SONOS].devices), 1) self.assertEqual(discover_mock.call_count, 1) - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - @mock.patch('soco.discover') - def test_ensure_setup_config_advertise_addr(self, discover_mock, - *args): - """Test an advertise address config'd by the HASS config file.""" - discover_mock.return_value = {SoCoMock('192.0.2.1')} - - config = { - DOMAIN: { - CONF_PLATFORM: 'sonos', - CONF_ADVERTISE_ADDR: '192.0.1.1', - } - } - - assert setup_component(self.hass, DOMAIN, config) - - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS].devices), 1) - self.assertEqual(discover_mock.call_count, 1) - self.assertEqual(soco.config.EVENT_ADVERTISE_IP, '192.0.1.1') - @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) def test_ensure_setup_config_hosts_string_single(self, *args): @@ -263,7 +241,7 @@ class TestSonosMediaPlayer(unittest.TestCase): def test_ensure_setup_sonos_discovery(self, *args): """Test a single device using the autodiscovery provided by Sonos.""" sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass)) - devices = self.hass.data[sonos.DATA_SONOS].devices + devices = list(self.hass.data[sonos.DATA_SONOS].devices) self.assertEqual(len(devices), 1) self.assertEqual(devices[0].name, 'Kitchen') @@ -275,7 +253,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS].devices[-1] + device = list(self.hass.data[sonos.DATA_SONOS].devices)[-1] device.hass = self.hass device.set_sleep_timer(30) @@ -289,7 +267,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS].devices[-1] + device = list(self.hass.data[sonos.DATA_SONOS].devices)[-1] device.hass = self.hass device.set_sleep_timer(None) @@ -298,12 +276,12 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('soco.alarms.Alarm') @mock.patch('socket.create_connection', side_effect=socket.error()) - def test_update_alarm(self, soco_mock, alarm_mock, *args): + def test_set_alarm(self, soco_mock, alarm_mock, *args): """Ensuring soco methods called for sonos_set_sleep_timer service.""" sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS].devices[-1] + device = list(self.hass.data[sonos.DATA_SONOS].devices)[-1] device.hass = self.hass alarm1 = alarms.Alarm(soco_mock) alarm1.configure_mock(_alarm_id="1", start_time=None, enabled=False, @@ -315,9 +293,9 @@ class TestSonosMediaPlayer(unittest.TestCase): 'include_linked_zones': True, 'volume': 0.30, } - device.update_alarm(alarm_id=2) + device.set_alarm(alarm_id=2) alarm1.save.assert_not_called() - device.update_alarm(alarm_id=1, **attrs) + device.set_alarm(alarm_id=1, **attrs) self.assertEqual(alarm1.enabled, attrs['enabled']) self.assertEqual(alarm1.start_time, attrs['time']) self.assertEqual(alarm1.include_linked_zones, @@ -333,7 +311,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS].devices[-1] + device = list(self.hass.data[sonos.DATA_SONOS].devices)[-1] device.hass = self.hass snapshotMock.return_value = True @@ -351,7 +329,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS].devices[-1] + device = list(self.hass.data[sonos.DATA_SONOS].devices)[-1] device.hass = self.hass restoreMock.return_value = True diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 1dd89a92f04..05c5de71b8c 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -59,7 +59,7 @@ class TestMQTTComponent(unittest.TestCase): """Helper for recording calls.""" self.calls.append(args) - def test_client_stops_on_home_assistant_start(self): + def aiohttp_client_stops_on_home_assistant_start(self): """Test if client stops on HA stop.""" self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP) self.hass.block_till_done() @@ -131,10 +131,56 @@ class TestMQTTComponent(unittest.TestCase): self.hass.data['mqtt'].async_publish.call_args[0][2], 2) self.assertFalse(self.hass.data['mqtt'].async_publish.call_args[0][3]) - def test_invalid_mqtt_topics(self): - """Test invalid topics.""" + def test_validate_topic(self): + """Test topic name/filter validation.""" + # Invalid UTF-8, must not contain U+D800 to U+DFFF. + self.assertRaises(vol.Invalid, mqtt.valid_topic, '\ud800') + self.assertRaises(vol.Invalid, mqtt.valid_topic, '\udfff') + # Topic MUST NOT be empty + self.assertRaises(vol.Invalid, mqtt.valid_topic, '') + # Topic MUST NOT be longer than 65535 encoded bytes. + self.assertRaises(vol.Invalid, mqtt.valid_topic, 'ü' * 32768) + # UTF-8 MUST NOT include null character + self.assertRaises(vol.Invalid, mqtt.valid_topic, 'bad\0one') + + # Topics "SHOULD NOT" include these special characters + # (not MUST NOT, RFC2119). The receiver MAY close the connection. + mqtt.valid_topic('\u0001') + mqtt.valid_topic('\u001F') + mqtt.valid_topic('\u009F') + mqtt.valid_topic('\u009F') + mqtt.valid_topic('\uffff') + + def test_validate_subscribe_topic(self): + """Test invalid subscribe topics.""" + mqtt.valid_subscribe_topic('#') + mqtt.valid_subscribe_topic('sport/#') + self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'sport/#/') + self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'foo/bar#') + self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'foo/#/bar') + + mqtt.valid_subscribe_topic('+') + mqtt.valid_subscribe_topic('+/tennis/#') + self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'sport+') + self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'sport+/') + self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'sport/+1') + self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'sport/+#') + self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'bad+topic') + mqtt.valid_subscribe_topic('sport/+/player1') + mqtt.valid_subscribe_topic('/finance') + mqtt.valid_subscribe_topic('+/+') + mqtt.valid_subscribe_topic('$SYS/#') + + def test_validate_publish_topic(self): + """Test invalid publish topics.""" + self.assertRaises(vol.Invalid, mqtt.valid_publish_topic, 'pub+') + self.assertRaises(vol.Invalid, mqtt.valid_publish_topic, 'pub/+') + self.assertRaises(vol.Invalid, mqtt.valid_publish_topic, '1#') self.assertRaises(vol.Invalid, mqtt.valid_publish_topic, 'bad+topic') - self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'bad\0one') + mqtt.valid_publish_topic('//') + + # Topic names beginning with $ SHOULD NOT be used, but can + mqtt.valid_publish_topic('$SYS/') # pylint: disable=invalid-name @@ -156,7 +202,7 @@ class TestMQTTCallbacks(unittest.TestCase): """Helper for recording calls.""" self.calls.append(args) - def test_client_starts_on_home_assistant_mqtt_setup(self): + def aiohttp_client_starts_on_home_assistant_mqtt_setup(self): """Test if client is connected after mqtt init on bootstrap.""" self.assertEqual(self.hass.data['mqtt']._mqttc.connect.call_count, 1) diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py index 5bd3270b922..71b472afe74 100644 --- a/tests/components/notify/test_demo.py +++ b/tests/components/notify/test_demo.py @@ -33,7 +33,7 @@ class TestNotifyDemo(unittest.TestCase): self.hass.bus.listen(demo.EVENT_NOTIFY, record_event) def tearDown(self): # pylint: disable=invalid-name - """"Stop down everything that was started.""" + """Stop down everything that was started.""" self.hass.stop() def _setup_notify(self): diff --git a/tests/components/notify/test_file.py b/tests/components/notify/test_file.py index 42b9eb9d82d..d59bbe4d720 100644 --- a/tests/components/notify/test_file.py +++ b/tests/components/notify/test_file.py @@ -20,7 +20,7 @@ class TestNotifyFile(unittest.TestCase): self.hass = get_test_home_assistant() def tearDown(self): # pylint: disable=invalid-name - """"Stop down everything that was started.""" + """Stop down everything that was started.""" self.hass.stop() def test_bad_config(self): @@ -35,28 +35,30 @@ class TestNotifyFile(unittest.TestCase): assert setup_component(self.hass, notify.DOMAIN, config) assert not handle_config[notify.DOMAIN] - def _test_notify_file(self, timestamp, mock_utcnow, mock_stat): + def _test_notify_file(self, timestamp): """Test the notify file output.""" - mock_utcnow.return_value = dt_util.as_utc(dt_util.now()) - mock_stat.return_value.st_size = 0 + filename = 'mock_file' + message = 'one, two, testing, testing' + with assert_setup_component(1) as handle_config: + self.assertTrue(setup_component(self.hass, notify.DOMAIN, { + 'notify': { + 'name': 'test', + 'platform': 'file', + 'filename': filename, + 'timestamp': timestamp, + } + })) + assert handle_config[notify.DOMAIN] m_open = mock_open() with patch( 'homeassistant.components.notify.file.open', m_open, create=True - ): - filename = 'mock_file' - message = 'one, two, testing, testing' - with assert_setup_component(1) as handle_config: - self.assertTrue(setup_component(self.hass, notify.DOMAIN, { - 'notify': { - 'name': 'test', - 'platform': 'file', - 'filename': filename, - 'timestamp': timestamp, - } - })) - assert handle_config[notify.DOMAIN] + ), patch('homeassistant.components.notify.file.os.stat') as mock_st, \ + patch('homeassistant.util.dt.utcnow', + return_value=dt_util.utcnow()): + + mock_st.return_value.st_size = 0 title = '{} notifications (Log started: {})\n{}\n'.format( ATTR_TITLE_DEFAULT, dt_util.utcnow().isoformat(), @@ -82,14 +84,10 @@ class TestNotifyFile(unittest.TestCase): dt_util.utcnow().isoformat(), message))] ) - @patch('homeassistant.components.notify.file.os.stat') - @patch('homeassistant.util.dt.utcnow') - def test_notify_file(self, mock_utcnow, mock_stat): + def test_notify_file(self): """Test the notify file output without timestamp.""" - self._test_notify_file(False, mock_utcnow, mock_stat) + self._test_notify_file(False) - @patch('homeassistant.components.notify.file.os.stat') - @patch('homeassistant.util.dt.utcnow') - def test_notify_file_timestamp(self, mock_utcnow, mock_stat): + def test_notify_file_timestamp(self): """Test the notify file output with timestamp.""" - self._test_notify_file(True, mock_utcnow, mock_stat) + self._test_notify_file(True) diff --git a/tests/components/notify/test_group.py b/tests/components/notify/test_group.py index c96a49d7cb3..a847de51142 100644 --- a/tests/components/notify/test_group.py +++ b/tests/components/notify/test_group.py @@ -53,7 +53,7 @@ class TestNotifyGroup(unittest.TestCase): assert self.service is not None def tearDown(self): # pylint: disable=invalid-name - """"Stop everything that was started.""" + """Stop everything that was started.""" self.hass.stop() def test_send_message_with_data(self): diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index 9ec71020ef1..318f3c7512c 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -49,7 +49,7 @@ REGISTER_URL = '/api/notify.html5' PUBLISH_URL = '/api/notify.html5/callback' -async def mock_client(hass, test_client, registrations=None): +async def mock_client(hass, aiohttp_client, registrations=None): """Create a test client for HTML5 views.""" if registrations is None: registrations = {} @@ -62,7 +62,7 @@ async def mock_client(hass, test_client, registrations=None): } }) - return await test_client(hass.http.app) + return await aiohttp_client(hass.http.app) class TestHtml5Notify(object): @@ -151,9 +151,9 @@ class TestHtml5Notify(object): assert mock_wp.mock_calls[4][2]['gcm_key'] is None -async def test_registering_new_device_view(hass, test_client): +async def test_registering_new_device_view(hass, aiohttp_client): """Test that the HTML view works.""" - client = await mock_client(hass, test_client) + client = await mock_client(hass, aiohttp_client) with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) @@ -165,9 +165,9 @@ async def test_registering_new_device_view(hass, test_client): } -async def test_registering_new_device_expiration_view(hass, test_client): +async def test_registering_new_device_expiration_view(hass, aiohttp_client): """Test that the HTML view works.""" - client = await mock_client(hass, test_client) + client = await mock_client(hass, aiohttp_client) with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) @@ -178,10 +178,10 @@ async def test_registering_new_device_expiration_view(hass, test_client): } -async def test_registering_new_device_fails_view(hass, test_client): +async def test_registering_new_device_fails_view(hass, aiohttp_client): """Test subs. are not altered when registering a new device fails.""" registrations = {} - client = await mock_client(hass, test_client, registrations) + client = await mock_client(hass, aiohttp_client, registrations) with patch('homeassistant.components.notify.html5.save_json', side_effect=HomeAssistantError()): @@ -191,10 +191,10 @@ async def test_registering_new_device_fails_view(hass, test_client): assert registrations == {} -async def test_registering_existing_device_view(hass, test_client): +async def test_registering_existing_device_view(hass, aiohttp_client): """Test subscription is updated when registering existing device.""" registrations = {} - client = await mock_client(hass, test_client, registrations) + client = await mock_client(hass, aiohttp_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) @@ -209,10 +209,10 @@ async def test_registering_existing_device_view(hass, test_client): } -async def test_registering_existing_device_fails_view(hass, test_client): +async def test_registering_existing_device_fails_view(hass, aiohttp_client): """Test sub. is not updated when registering existing device fails.""" registrations = {} - client = await mock_client(hass, test_client, registrations) + client = await mock_client(hass, aiohttp_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) @@ -225,9 +225,9 @@ async def test_registering_existing_device_fails_view(hass, test_client): } -async def test_registering_new_device_validation(hass, test_client): +async def test_registering_new_device_validation(hass, aiohttp_client): """Test various errors when registering a new device.""" - client = await mock_client(hass, test_client) + client = await mock_client(hass, aiohttp_client) resp = await client.post(REGISTER_URL, data=json.dumps({ 'browser': 'invalid browser', @@ -249,13 +249,13 @@ async def test_registering_new_device_validation(hass, test_client): assert resp.status == 400 -async def test_unregistering_device_view(hass, test_client): +async def test_unregistering_device_view(hass, aiohttp_client): """Test that the HTML unregister view works.""" registrations = { 'some device': SUBSCRIPTION_1, 'other device': SUBSCRIPTION_2, } - client = await mock_client(hass, test_client, registrations) + client = await mock_client(hass, aiohttp_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = await client.delete(REGISTER_URL, data=json.dumps({ @@ -269,11 +269,11 @@ async def test_unregistering_device_view(hass, test_client): } -async def test_unregister_device_view_handle_unknown_subscription(hass, - test_client): +async def test_unregister_device_view_handle_unknown_subscription( + hass, aiohttp_client): """Test that the HTML unregister view handles unknown subscriptions.""" registrations = {} - client = await mock_client(hass, test_client, registrations) + client = await mock_client(hass, aiohttp_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = await client.delete(REGISTER_URL, data=json.dumps({ @@ -285,13 +285,14 @@ async def test_unregister_device_view_handle_unknown_subscription(hass, assert len(mock_save.mock_calls) == 0 -async def test_unregistering_device_view_handles_save_error(hass, test_client): +async def test_unregistering_device_view_handles_save_error( + hass, aiohttp_client): """Test that the HTML unregister view handles save errors.""" registrations = { 'some device': SUBSCRIPTION_1, 'other device': SUBSCRIPTION_2, } - client = await mock_client(hass, test_client, registrations) + client = await mock_client(hass, aiohttp_client, registrations) with patch('homeassistant.components.notify.html5.save_json', side_effect=HomeAssistantError()): @@ -306,9 +307,9 @@ async def test_unregistering_device_view_handles_save_error(hass, test_client): } -async def test_callback_view_no_jwt(hass, test_client): +async def test_callback_view_no_jwt(hass, aiohttp_client): """Test that the notification callback view works without JWT.""" - client = await mock_client(hass, test_client) + client = await mock_client(hass, aiohttp_client) resp = await client.post(PUBLISH_URL, data=json.dumps({ 'type': 'push', 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72' @@ -317,12 +318,12 @@ async def test_callback_view_no_jwt(hass, test_client): assert resp.status == 401, resp.response -async def test_callback_view_with_jwt(hass, test_client): +async def test_callback_view_with_jwt(hass, aiohttp_client): """Test that the notification callback view works with JWT.""" registrations = { 'device': SUBSCRIPTION_1 } - client = await mock_client(hass, test_client, registrations) + client = await mock_client(hass, aiohttp_client, registrations) with patch('pywebpush.WebPusher') as mock_wp: await hass.services.async_call('notify', 'notify', { diff --git a/tests/components/notify/test_smtp.py b/tests/components/notify/test_smtp.py index 127eecae2b7..29e34974c6c 100644 --- a/tests/components/notify/test_smtp.py +++ b/tests/components/notify/test_smtp.py @@ -27,7 +27,7 @@ class TestNotifySmtp(unittest.TestCase): 'HomeAssistant', 0) def tearDown(self): # pylint: disable=invalid-name - """"Stop down everything that was started.""" + """Stop down everything that was started.""" self.hass.stop() @patch('email.utils.make_msgid', return_value='') diff --git a/tests/components/scene/test_deconz.py b/tests/components/scene/test_deconz.py new file mode 100644 index 00000000000..53f25808be2 --- /dev/null +++ b/tests/components/scene/test_deconz.py @@ -0,0 +1,57 @@ +"""deCONZ scenes platform tests.""" +from unittest.mock import Mock, patch + +from homeassistant import config_entries +from homeassistant.components import deconz + +from tests.common import mock_coro + + +GROUP = { + "1": { + "id": "Group 1 id", + "name": "Group 1 name", + "state": {}, + "action": {}, + "scenes": [{ + "id": "1", + "name": "Scene 1" + }], + } +} + + +async def setup_bridge(hass, data): + """Load the deCONZ scene platform.""" + from pydeconz import DeconzSession + loop = Mock() + session = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + bridge = DeconzSession(loop, session, **entry.data) + with patch('pydeconz.DeconzSession.async_get_state', + return_value=mock_coro(data)): + await bridge.async_load_parameters() + hass.data[deconz.DOMAIN] = bridge + hass.data[deconz.DATA_DECONZ_ID] = {} + config_entry = config_entries.ConfigEntry( + 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test') + await hass.config_entries.async_forward_entry_setup(config_entry, 'scene') + # To flush out the service call to update the group + await hass.async_block_till_done() + + +async def test_no_scenes(hass): + """Test the update_lights function with some lights.""" + data = {} + await setup_bridge(hass, data) + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + assert len(hass.states.async_all()) == 0 + + +async def test_scenes(hass): + """Test the update_lights function with some lights.""" + data = {"groups": GROUP} + await setup_bridge(hass, data) + assert "scene.group_1_name_scene_1" in hass.data[deconz.DATA_DECONZ_ID] + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/scene/test_init.py b/tests/components/scene/test_init.py index 25ea818c774..a832e249832 100644 --- a/tests/components/scene/test_init.py +++ b/tests/components/scene/test_init.py @@ -16,7 +16,7 @@ class TestScene(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - test_light = loader.get_component('light.test') + test_light = loader.get_component(self.hass, 'light.test') test_light.init() self.assertTrue(setup_component(self.hass, light.DOMAIN, { diff --git a/tests/components/sensor/test_bom.py b/tests/components/sensor/test_bom.py new file mode 100644 index 00000000000..5e5a829662a --- /dev/null +++ b/tests/components/sensor/test_bom.py @@ -0,0 +1,99 @@ +"""The tests for the BOM Weather sensor platform.""" +import json +import re +import unittest +from unittest.mock import patch +from urllib.parse import urlparse + +import requests +from tests.common import ( + assert_setup_component, get_test_home_assistant, load_fixture) + +from homeassistant.components import sensor +from homeassistant.setup import setup_component + +VALID_CONFIG = { + 'platform': 'bom', + 'station': 'IDN60901.94767', + 'name': 'Fake', + 'monitored_conditions': [ + 'apparent_t', + 'press', + 'weather' + ] +} + + +def mocked_requests(*args, **kwargs): + """Mock requests.get invocations.""" + class MockResponse: + """Class to represent a mocked response.""" + + def __init__(self, json_data, status_code): + """Initialize the mock response class.""" + self.json_data = json_data + self.status_code = status_code + + def json(self): + """Return the json of the response.""" + return self.json_data + + @property + def content(self): + """Return the content of the response.""" + return self.json() + + def raise_for_status(self): + """Raise an HTTPError if status is not 200.""" + if self.status_code != 200: + raise requests.HTTPError(self.status_code) + + url = urlparse(args[0]) + if re.match(r'^/fwo/[\w]+/[\w.]+\.json', url.path): + return MockResponse(json.loads(load_fixture('bom_weather.json')), 200) + + raise NotImplementedError('Unknown route {}'.format(url.path)) + + +class TestBOMWeatherSensor(unittest.TestCase): + """Test the BOM Weather sensor.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('requests.get', side_effect=mocked_requests) + def test_setup(self, mock_get): + """Test the setup with custom settings.""" + with assert_setup_component(1, sensor.DOMAIN): + self.assertTrue(setup_component(self.hass, sensor.DOMAIN, { + 'sensor': VALID_CONFIG})) + + fake_entities = [ + 'bom_fake_feels_like_c', + 'bom_fake_pressure_mb', + 'bom_fake_weather'] + + for entity_id in fake_entities: + state = self.hass.states.get('sensor.{}'.format(entity_id)) + self.assertIsNotNone(state) + + @patch('requests.get', side_effect=mocked_requests) + def test_sensor_values(self, mock_get): + """Test retrieval of sensor values.""" + self.assertTrue(setup_component( + self.hass, sensor.DOMAIN, {'sensor': VALID_CONFIG})) + + weather = self.hass.states.get('sensor.bom_fake_weather').state + self.assertEqual('Fine', weather) + + pressure = self.hass.states.get('sensor.bom_fake_pressure_mb').state + self.assertEqual('1021.7', pressure) + + feels_like = self.hass.states.get('sensor.bom_fake_feels_like_c').state + self.assertEqual('25.0', feels_like) diff --git a/tests/components/sensor/test_canary.py b/tests/components/sensor/test_canary.py index 79e2bf4ee35..346929a4685 100644 --- a/tests/components/sensor/test_canary.py +++ b/tests/components/sensor/test_canary.py @@ -40,9 +40,9 @@ class TestCanarySensorSetup(unittest.TestCase): def test_setup_sensors(self): """Test the sensor setup.""" - online_device_at_home = mock_device(20, "Dining Room", True) - offline_device_at_home = mock_device(21, "Front Yard", False) - online_device_at_work = mock_device(22, "Office", True) + online_device_at_home = mock_device(20, "Dining Room", True, "Canary") + offline_device_at_home = mock_device(21, "Front Yard", False, "Canary") + online_device_at_work = mock_device(22, "Office", True, "Canary") self.hass.data[DATA_CANARY] = Mock() self.hass.data[DATA_CANARY].locations = [ @@ -57,7 +57,7 @@ class TestCanarySensorSetup(unittest.TestCase): def test_temperature_sensor(self): """Test temperature sensor with fahrenheit.""" - device = mock_device(10, "Family Room") + device = mock_device(10, "Family Room", "Canary") location = mock_location("Home", False) data = Mock() @@ -69,10 +69,11 @@ class TestCanarySensorSetup(unittest.TestCase): self.assertEqual("Home Family Room Temperature", sensor.name) self.assertEqual("°C", sensor.unit_of_measurement) self.assertEqual(21.12, sensor.state) + self.assertEqual("mdi:thermometer", sensor.icon) def test_temperature_sensor_with_none_sensor_value(self): """Test temperature sensor with fahrenheit.""" - device = mock_device(10, "Family Room") + device = mock_device(10, "Family Room", "Canary") location = mock_location("Home", False) data = Mock() @@ -85,7 +86,7 @@ class TestCanarySensorSetup(unittest.TestCase): def test_humidity_sensor(self): """Test humidity sensor.""" - device = mock_device(10, "Family Room") + device = mock_device(10, "Family Room", "Canary") location = mock_location("Home") data = Mock() @@ -97,10 +98,11 @@ class TestCanarySensorSetup(unittest.TestCase): self.assertEqual("Home Family Room Humidity", sensor.name) self.assertEqual("%", sensor.unit_of_measurement) self.assertEqual(50.46, sensor.state) + self.assertEqual("mdi:water-percent", sensor.icon) def test_air_quality_sensor_with_very_abnormal_reading(self): """Test air quality sensor.""" - device = mock_device(10, "Family Room") + device = mock_device(10, "Family Room", "Canary") location = mock_location("Home") data = Mock() @@ -112,13 +114,14 @@ class TestCanarySensorSetup(unittest.TestCase): self.assertEqual("Home Family Room Air Quality", sensor.name) self.assertEqual(None, sensor.unit_of_measurement) self.assertEqual(0.4, sensor.state) + self.assertEqual("mdi:weather-windy", sensor.icon) air_quality = sensor.device_state_attributes[ATTR_AIR_QUALITY] self.assertEqual(STATE_AIR_QUALITY_VERY_ABNORMAL, air_quality) def test_air_quality_sensor_with_abnormal_reading(self): """Test air quality sensor.""" - device = mock_device(10, "Family Room") + device = mock_device(10, "Family Room", "Canary") location = mock_location("Home") data = Mock() @@ -130,13 +133,14 @@ class TestCanarySensorSetup(unittest.TestCase): self.assertEqual("Home Family Room Air Quality", sensor.name) self.assertEqual(None, sensor.unit_of_measurement) self.assertEqual(0.59, sensor.state) + self.assertEqual("mdi:weather-windy", sensor.icon) air_quality = sensor.device_state_attributes[ATTR_AIR_QUALITY] self.assertEqual(STATE_AIR_QUALITY_ABNORMAL, air_quality) def test_air_quality_sensor_with_normal_reading(self): """Test air quality sensor.""" - device = mock_device(10, "Family Room") + device = mock_device(10, "Family Room", "Canary") location = mock_location("Home") data = Mock() @@ -148,13 +152,14 @@ class TestCanarySensorSetup(unittest.TestCase): self.assertEqual("Home Family Room Air Quality", sensor.name) self.assertEqual(None, sensor.unit_of_measurement) self.assertEqual(1.0, sensor.state) + self.assertEqual("mdi:weather-windy", sensor.icon) air_quality = sensor.device_state_attributes[ATTR_AIR_QUALITY] self.assertEqual(STATE_AIR_QUALITY_NORMAL, air_quality) def test_air_quality_sensor_with_none_sensor_value(self): """Test air quality sensor.""" - device = mock_device(10, "Family Room") + device = mock_device(10, "Family Room", "Canary") location = mock_location("Home") data = Mock() @@ -165,3 +170,35 @@ class TestCanarySensorSetup(unittest.TestCase): self.assertEqual(None, sensor.state) self.assertEqual(None, sensor.device_state_attributes) + + def test_battery_sensor(self): + """Test battery sensor.""" + device = mock_device(10, "Family Room", "Canary Flex") + location = mock_location("Home") + + data = Mock() + data.get_reading.return_value = 70.4567 + + sensor = CanarySensor(data, SENSOR_TYPES[4], location, device) + sensor.update() + + self.assertEqual("Home Family Room Battery", sensor.name) + self.assertEqual("%", sensor.unit_of_measurement) + self.assertEqual(70.46, sensor.state) + self.assertEqual("mdi:battery-70", sensor.icon) + + def test_wifi_sensor(self): + """Test battery sensor.""" + device = mock_device(10, "Family Room", "Canary Flex") + location = mock_location("Home") + + data = Mock() + data.get_reading.return_value = -57 + + sensor = CanarySensor(data, SENSOR_TYPES[3], location, device) + sensor.update() + + self.assertEqual("Home Family Room Wifi", sensor.name) + self.assertEqual("dBm", sensor.unit_of_measurement) + self.assertEqual(-57, sensor.state) + self.assertEqual("mdi:wifi", sensor.icon) diff --git a/tests/components/sensor/test_coinmarketcap.py b/tests/components/sensor/test_coinmarketcap.py index 15c254bfb27..37a63e5cba5 100644 --- a/tests/components/sensor/test_coinmarketcap.py +++ b/tests/components/sensor/test_coinmarketcap.py @@ -11,8 +11,9 @@ from tests.common import ( VALID_CONFIG = { 'platform': 'coinmarketcap', - 'currency': 'ethereum', + 'currency_id': 1027, 'display_currency': 'EUR', + 'display_currency_decimals': 3 } @@ -39,6 +40,6 @@ class TestCoinMarketCapSensor(unittest.TestCase): state = self.hass.states.get('sensor.ethereum') assert state is not None - assert state.state == '240.47' + assert state.state == '493.455' assert state.attributes.get('symbol') == 'ETH' assert state.attributes.get('unit_of_measurement') == 'EUR' diff --git a/tests/components/sensor/test_darksky.py b/tests/components/sensor/test_darksky.py index 7ee04b0df4c..9300ecef432 100644 --- a/tests/components/sensor/test_darksky.py +++ b/tests/components/sensor/test_darksky.py @@ -2,16 +2,69 @@ import re import unittest from unittest.mock import MagicMock, patch +from datetime import timedelta -import forecastio from requests.exceptions import HTTPError import requests_mock -from datetime import timedelta + +import forecastio from homeassistant.components.sensor import darksky from homeassistant.setup import setup_component -from tests.common import load_fixture, get_test_home_assistant +from tests.common import (load_fixture, get_test_home_assistant, + MockDependency) + +VALID_CONFIG_MINIMAL = { + 'sensor': { + 'platform': 'darksky', + 'api_key': 'foo', + 'forecast': [1, 2], + 'monitored_conditions': ['summary', 'icon', 'temperature_max'], + 'update_interval': timedelta(seconds=120), + } +} + +INVALID_CONFIG_MINIMAL = { + 'sensor': { + 'platform': 'darksky', + 'api_key': 'foo', + 'forecast': [1, 2], + 'monitored_conditions': ['sumary', 'iocn', 'temperature_max'], + 'update_interval': timedelta(seconds=120), + } +} + +VALID_CONFIG_LANG_DE = { + 'sensor': { + 'platform': 'darksky', + 'api_key': 'foo', + 'forecast': [1, 2], + 'units': 'us', + 'language': 'de', + 'monitored_conditions': ['summary', 'icon', 'temperature_max', + 'minutely_summary', 'hourly_summary', + 'daily_summary', 'humidity', ], + 'update_interval': timedelta(seconds=120), + } +} + +INVALID_CONFIG_LANG = { + 'sensor': { + 'platform': 'darksky', + 'api_key': 'foo', + 'forecast': [1, 2], + 'language': 'yz', + 'monitored_conditions': ['summary', 'icon', 'temperature_max'], + 'update_interval': timedelta(seconds=120), + } +} + + +def load_forecastMock(key, lat, lon, + units, lang): # pylint: disable=invalid-name + """Mock darksky forecast loading.""" + return '' class TestDarkSkySetup(unittest.TestCase): @@ -30,12 +83,6 @@ class TestDarkSkySetup(unittest.TestCase): """Initialize values for this testcase class.""" self.hass = get_test_home_assistant() self.key = 'foo' - self.config = { - 'api_key': 'foo', - 'forecast': [1, 2], - 'monitored_conditions': ['summary', 'icon', 'temperature_max'], - 'update_interval': timedelta(seconds=120), - } self.lat = self.hass.config.latitude = 37.8267 self.lon = self.hass.config.longitude = -122.423 self.entities = [] @@ -44,10 +91,41 @@ class TestDarkSkySetup(unittest.TestCase): """Stop everything that was started.""" self.hass.stop() - def test_setup_with_config(self): + @MockDependency('forecastio') + @patch('forecastio.load_forecast', new=load_forecastMock) + def test_setup_with_config(self, mock_forecastio): """Test the platform setup with configuration.""" - self.assertTrue( - setup_component(self.hass, 'sensor', {'darksky': self.config})) + setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL) + + state = self.hass.states.get('sensor.dark_sky_summary') + assert state is not None + + @MockDependency('forecastio') + @patch('forecastio.load_forecast', new=load_forecastMock) + def test_setup_with_invalid_config(self, mock_forecastio): + """Test the platform setup with invalid configuration.""" + setup_component(self.hass, 'sensor', INVALID_CONFIG_MINIMAL) + + state = self.hass.states.get('sensor.dark_sky_summary') + assert state is None + + @MockDependency('forecastio') + @patch('forecastio.load_forecast', new=load_forecastMock) + def test_setup_with_language_config(self, mock_forecastio): + """Test the platform setup with language configuration.""" + setup_component(self.hass, 'sensor', VALID_CONFIG_LANG_DE) + + state = self.hass.states.get('sensor.dark_sky_summary') + assert state is not None + + @MockDependency('forecastio') + @patch('forecastio.load_forecast', new=load_forecastMock) + def test_setup_with_invalid_language_config(self, mock_forecastio): + """Test the platform setup with language configuration.""" + setup_component(self.hass, 'sensor', INVALID_CONFIG_LANG) + + state = self.hass.states.get('sensor.dark_sky_summary') + assert state is None @patch('forecastio.api.get_forecast') def test_setup_bad_api_key(self, mock_get_forecast): @@ -60,7 +138,8 @@ class TestDarkSkySetup(unittest.TestCase): msg = '400 Client Error: Bad Request for url: {}'.format(url) mock_get_forecast.side_effect = HTTPError(msg,) - response = darksky.setup_platform(self.hass, self.config, MagicMock()) + response = darksky.setup_platform(self.hass, VALID_CONFIG_MINIMAL, + MagicMock()) self.assertFalse(response) @requests_mock.Mocker() @@ -69,9 +148,16 @@ class TestDarkSkySetup(unittest.TestCase): """Test for successfully setting up the forecast.io platform.""" uri = (r'https://api.(darksky.net|forecast.io)\/forecast\/(\w+)\/' r'(-?\d+\.?\d*),(-?\d+\.?\d*)') - mock_req.get(re.compile(uri), - text=load_fixture('darksky.json')) - darksky.setup_platform(self.hass, self.config, self.add_entities) + mock_req.get(re.compile(uri), text=load_fixture('darksky.json')) + + assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL) + self.assertTrue(mock_get_forecast.called) self.assertEqual(mock_get_forecast.call_count, 1) - self.assertEqual(len(self.entities), 7) + self.assertEqual(len(self.hass.states.entity_ids()), 7) + + state = self.hass.states.get('sensor.dark_sky_summary') + assert state is not None + self.assertEqual(state.state, 'Clear') + self.assertEqual(state.attributes.get('friendly_name'), + 'Dark Sky Summary') diff --git a/tests/components/sensor/test_deconz.py b/tests/components/sensor/test_deconz.py new file mode 100644 index 00000000000..d7cdb458646 --- /dev/null +++ b/tests/components/sensor/test_deconz.py @@ -0,0 +1,113 @@ +"""deCONZ sensor platform tests.""" +from unittest.mock import Mock, patch + + +from homeassistant import config_entries +from homeassistant.components import deconz +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import mock_coro + + +SENSOR = { + "1": { + "id": "Sensor 1 id", + "name": "Sensor 1 name", + "type": "ZHATemperature", + "state": {"temperature": False}, + "config": {} + }, + "2": { + "id": "Sensor 2 id", + "name": "Sensor 2 name", + "type": "ZHAPresence", + "state": {"presence": False}, + "config": {} + }, + "3": { + "id": "Sensor 3 id", + "name": "Sensor 3 name", + "type": "ZHASwitch", + "state": {"buttonevent": 1000}, + "config": {} + }, + "4": { + "id": "Sensor 4 id", + "name": "Sensor 4 name", + "type": "ZHASwitch", + "state": {"buttonevent": 1000}, + "config": {"battery": 100} + } +} + + +async def setup_bridge(hass, data, allow_clip_sensor=True): + """Load the deCONZ sensor platform.""" + from pydeconz import DeconzSession + loop = Mock() + session = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + bridge = DeconzSession(loop, session, **entry.data) + with patch('pydeconz.DeconzSession.async_get_state', + return_value=mock_coro(data)): + await bridge.async_load_parameters() + hass.data[deconz.DOMAIN] = bridge + hass.data[deconz.DATA_DECONZ_UNSUB] = [] + hass.data[deconz.DATA_DECONZ_EVENT] = [] + hass.data[deconz.DATA_DECONZ_ID] = {} + config_entry = config_entries.ConfigEntry( + 1, deconz.DOMAIN, 'Mock Title', + {'host': 'mock-host', 'allow_clip_sensor': allow_clip_sensor}, 'test') + await hass.config_entries.async_forward_entry_setup(config_entry, 'sensor') + # To flush out the service call to update the group + await hass.async_block_till_done() + + +async def test_no_sensors(hass): + """Test that no sensors in deconz results in no sensor entities.""" + data = {} + await setup_bridge(hass, data) + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + assert len(hass.states.async_all()) == 0 + + +async def test_sensors(hass): + """Test successful creation of sensor entities.""" + data = {"sensors": SENSOR} + await setup_bridge(hass, data) + assert "sensor.sensor_1_name" in hass.data[deconz.DATA_DECONZ_ID] + assert "sensor.sensor_2_name" not in hass.data[deconz.DATA_DECONZ_ID] + assert "sensor.sensor_3_name" not in hass.data[deconz.DATA_DECONZ_ID] + assert "sensor.sensor_3_name_battery_level" not in \ + hass.data[deconz.DATA_DECONZ_ID] + assert "sensor.sensor_4_name" not in hass.data[deconz.DATA_DECONZ_ID] + assert "sensor.sensor_4_name_battery_level" in \ + hass.data[deconz.DATA_DECONZ_ID] + assert len(hass.states.async_all()) == 2 + + +async def test_add_new_sensor(hass): + """Test successful creation of sensor entities.""" + data = {} + await setup_bridge(hass, data) + sensor = Mock() + sensor.name = 'name' + sensor.type = 'ZHATemperature' + sensor.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_sensor', [sensor]) + await hass.async_block_till_done() + assert "sensor.name" in hass.data[deconz.DATA_DECONZ_ID] + + +async def test_do_not_allow_clipsensor(hass): + """Test that clip sensors can be ignored.""" + data = {} + await setup_bridge(hass, data, allow_clip_sensor=False) + sensor = Mock() + sensor.name = 'name' + sensor.type = 'CLIPTemperature' + sensor.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_sensor', [sensor]) + await hass.async_block_till_done() + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 diff --git a/tests/components/sensor/test_file.py b/tests/components/sensor/test_file.py index aa048f7a62e..7171289de69 100644 --- a/tests/components/sensor/test_file.py +++ b/tests/components/sensor/test_file.py @@ -18,6 +18,8 @@ class TestFileSensor(unittest.TestCase): def setup_method(self, method): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + # Patch out 'is_allowed_path' as the mock files aren't allowed + self.hass.config.is_allowed_path = Mock(return_value=True) mock_registry(self.hass) def teardown_method(self, method): diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index dd1112d65f8..8e79306fe13 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -1,10 +1,15 @@ """The test for the data filter sensor platform.""" +from datetime import timedelta import unittest +from unittest.mock import patch from homeassistant.components.sensor.filter import ( - LowPassFilter, OutlierFilter, ThrottleFilter) + LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter) +import homeassistant.util.dt as dt_util from homeassistant.setup import setup_component -from tests.common import get_test_home_assistant, assert_setup_component +import homeassistant.core as ha +from tests.common import (get_test_home_assistant, assert_setup_component, + init_recorder_component) class TestFilterSensor(unittest.TestCase): @@ -13,12 +18,24 @@ class TestFilterSensor(unittest.TestCase): def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.values = [20, 19, 18, 21, 22, 0] + raw_values = [20, 19, 18, 21, 22, 0] + self.values = [] + + timestamp = dt_util.utcnow() + for val in raw_values: + self.values.append(ha.State('sensor.test_monitored', + val, last_updated=timestamp)) + timestamp += timedelta(minutes=1) def teardown_method(self, method): """Stop everything that was started.""" self.hass.stop() + def init_recorder(self): + """Initialize the recorder.""" + init_recorder_component(self.hass) + self.hass.start() + def test_setup_fail(self): """Test if filter doesn't exist.""" config = { @@ -33,41 +50,76 @@ class TestFilterSensor(unittest.TestCase): def test_chain(self): """Test if filter chaining works.""" + self.init_recorder() config = { + 'history': { + }, 'sensor': { 'platform': 'filter', 'name': 'test', 'entity_id': 'sensor.test_monitored', + 'history_period': '00:05', 'filters': [{ 'filter': 'outlier', + 'window_size': 10, 'radius': 4.0 }, { 'filter': 'lowpass', - 'window_size': 4, 'time_constant': 10, 'precision': 2 + }, { + 'filter': 'throttle', + 'window_size': 1 }] } } - with assert_setup_component(1): - assert setup_component(self.hass, 'sensor', config) + t_0 = dt_util.utcnow() - timedelta(minutes=1) + t_1 = dt_util.utcnow() - timedelta(minutes=2) + t_2 = dt_util.utcnow() - timedelta(minutes=3) - for value in self.values: - self.hass.states.set(config['sensor']['entity_id'], value) - self.hass.block_till_done() + fake_states = { + 'sensor.test_monitored': [ + ha.State('sensor.test_monitored', 18.0, last_changed=t_0), + ha.State('sensor.test_monitored', 19.0, last_changed=t_1), + ha.State('sensor.test_monitored', 18.2, last_changed=t_2), + ] + } - state = self.hass.states.get('sensor.test') - self.assertEqual('20.25', state.state) + with patch('homeassistant.components.history.' + 'state_changes_during_period', return_value=fake_states): + with patch('homeassistant.components.history.' + 'get_last_state_changes', return_value=fake_states): + with assert_setup_component(1, 'sensor'): + assert setup_component(self.hass, 'sensor', config) + + for value in self.values: + self.hass.states.set( + config['sensor']['entity_id'], value.state) + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test') + self.assertEqual('17.05', state.state) def test_outlier(self): """Test if outlier filter works.""" - filt = OutlierFilter(window_size=10, + filt = OutlierFilter(window_size=3, precision=2, entity=None, radius=4.0) for state in self.values: filtered = filt.filter_state(state) - self.assertEqual(22, filtered) + self.assertEqual(22, filtered.state) + + def test_initial_outlier(self): + """Test issue #13363.""" + filt = OutlierFilter(window_size=3, + precision=2, + entity=None, + radius=4.0) + out = ha.State('sensor.test_monitored', 4000) + for state in [out]+self.values: + filtered = filt.filter_state(state) + self.assertEqual(22, filtered.state) def test_lowpass(self): """Test if lowpass filter works.""" @@ -77,7 +129,7 @@ class TestFilterSensor(unittest.TestCase): time_constant=10) for state in self.values: filtered = filt.filter_state(state) - self.assertEqual(18.05, filtered) + self.assertEqual(18.05, filtered.state) def test_throttle(self): """Test if lowpass filter works.""" @@ -89,4 +141,14 @@ class TestFilterSensor(unittest.TestCase): new_state = filt.filter_state(state) if not filt.skip_processing: filtered.append(new_state) - self.assertEqual([20, 21], filtered) + self.assertEqual([20, 21], [f.state for f in filtered]) + + def test_time_sma(self): + """Test if time_sma filter works.""" + filt = TimeSMAFilter(window_size=timedelta(minutes=2), + precision=2, + entity=None, + type='last') + for state in self.values: + filtered = filt.filter_state(state) + self.assertEqual(21.5, filtered.state) diff --git a/tests/components/sensor/test_foobot.py b/tests/components/sensor/test_foobot.py new file mode 100644 index 00000000000..322f2b3f2a8 --- /dev/null +++ b/tests/components/sensor/test_foobot.py @@ -0,0 +1,81 @@ +"""The tests for the Foobot sensor platform.""" + +import re +import asyncio +from unittest.mock import MagicMock +import pytest + + +import homeassistant.components.sensor as sensor +from homeassistant.components.sensor import foobot +from homeassistant.const import (TEMP_CELSIUS) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.setup import async_setup_component +from tests.common import load_fixture + +VALID_CONFIG = { + 'platform': 'foobot', + 'token': 'adfdsfasd', + 'username': 'example@example.com', +} + + +async def test_default_setup(hass, aioclient_mock): + """Test the default setup.""" + aioclient_mock.get(re.compile('api.foobot.io/v2/owner/.*'), + text=load_fixture('foobot_devices.json')) + aioclient_mock.get(re.compile('api.foobot.io/v2/device/.*'), + text=load_fixture('foobot_data.json')) + assert await async_setup_component(hass, sensor.DOMAIN, + {'sensor': VALID_CONFIG}) + + metrics = {'co2': ['1232.0', 'ppm'], + 'temperature': ['21.1', TEMP_CELSIUS], + 'humidity': ['49.5', '%'], + 'pm25': ['144.8', 'µg/m3'], + 'voc': ['340.7', 'ppb'], + 'index': ['138.9', '%']} + + for name, value in metrics.items(): + state = hass.states.get('sensor.foobot_happybot_%s' % name) + assert state.state == value[0] + assert state.attributes.get('unit_of_measurement') == value[1] + + +async def test_setup_timeout_error(hass, aioclient_mock): + """Expected failures caused by a timeout in API response.""" + fake_async_add_devices = MagicMock() + + aioclient_mock.get(re.compile('api.foobot.io/v2/owner/.*'), + exc=asyncio.TimeoutError()) + with pytest.raises(PlatformNotReady): + await foobot.async_setup_platform(hass, {'sensor': VALID_CONFIG}, + fake_async_add_devices) + + +async def test_setup_permanent_error(hass, aioclient_mock): + """Expected failures caused by permanent errors in API response.""" + fake_async_add_devices = MagicMock() + + errors = [400, 401, 403] + for error in errors: + aioclient_mock.get(re.compile('api.foobot.io/v2/owner/.*'), + status=error) + result = await foobot.async_setup_platform(hass, + {'sensor': VALID_CONFIG}, + fake_async_add_devices) + assert result is None + + +async def test_setup_temporary_error(hass, aioclient_mock): + """Expected failures caused by temporary errors in API response.""" + fake_async_add_devices = MagicMock() + + errors = [429, 500] + for error in errors: + aioclient_mock.get(re.compile('api.foobot.io/v2/owner/.*'), + status=error) + with pytest.raises(PlatformNotReady): + await foobot.async_setup_platform(hass, + {'sensor': VALID_CONFIG}, + fake_async_add_devices) diff --git a/tests/components/sensor/test_mhz19.py b/tests/components/sensor/test_mhz19.py index 6948a952c31..6d071489691 100644 --- a/tests/components/sensor/test_mhz19.py +++ b/tests/components/sensor/test_mhz19.py @@ -52,7 +52,7 @@ class TestMHZ19Sensor(unittest.TestCase): @patch('pmsensor.co2sensor.read_mh_z19_with_temperature', side_effect=OSError('test error')) - def test_client_update_oserror(self, mock_function): + def aiohttp_client_update_oserror(self, mock_function): """Test MHZClient when library throws OSError.""" from pmsensor import co2sensor client = mhz19.MHZClient(co2sensor, 'test.serial') @@ -61,7 +61,7 @@ class TestMHZ19Sensor(unittest.TestCase): @patch('pmsensor.co2sensor.read_mh_z19_with_temperature', return_value=(5001, 24)) - def test_client_update_ppm_overflow(self, mock_function): + def aiohttp_client_update_ppm_overflow(self, mock_function): """Test MHZClient when ppm is too high.""" from pmsensor import co2sensor client = mhz19.MHZClient(co2sensor, 'test.serial') @@ -70,7 +70,7 @@ class TestMHZ19Sensor(unittest.TestCase): @patch('pmsensor.co2sensor.read_mh_z19_with_temperature', return_value=(1000, 24)) - def test_client_update_good_read(self, mock_function): + def aiohttp_client_update_good_read(self, mock_function): """Test MHZClient when ppm is too high.""" from pmsensor import co2sensor client = mhz19.MHZClient(co2sensor, 'test.serial') diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py index b23d89e3057..2583f52b3d2 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/sensor/test_mqtt.py @@ -10,7 +10,8 @@ import homeassistant.components.sensor as sensor from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE import homeassistant.util.dt as dt_util -from tests.common import mock_mqtt_component, fire_mqtt_message +from tests.common import mock_mqtt_component, fire_mqtt_message, \ + assert_setup_component from tests.common import get_test_home_assistant, mock_component @@ -329,3 +330,57 @@ class TestSensorMQTT(unittest.TestCase): self.assertEqual('100', state.attributes.get('val')) self.assertEqual('100', state.state) + + def test_unique_id(self): + """Test unique id option only creates one sensor per unique_id.""" + assert setup_component(self.hass, sensor.DOMAIN, { + sensor.DOMAIN: [{ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }, { + 'platform': 'mqtt', + 'name': 'Test 2', + 'state_topic': 'test-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }] + }) + + fire_mqtt_message(self.hass, 'test-topic', 'payload') + self.hass.block_till_done() + + assert len(self.hass.states.all()) == 1 + + def test_invalid_device_class(self): + """Test device_class option with invalid value.""" + with assert_setup_component(0): + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'device_class': 'foobarnotreal' + } + }) + + def test_valid_device_class(self): + """Test device_class option with valid values.""" + assert setup_component(self.hass, 'sensor', { + 'sensor': [{ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'device_class': 'temperature' + }, { + 'platform': 'mqtt', + 'name': 'Test 2', + 'state_topic': 'test-topic', + }] + }) + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test_1') + assert state.attributes['device_class'] == 'temperature' + state = self.hass.states.get('sensor.test_2') + assert 'device_class' not in state.attributes diff --git a/tests/components/sensor/test_sigfox.py b/tests/components/sensor/test_sigfox.py new file mode 100644 index 00000000000..569fab584ad --- /dev/null +++ b/tests/components/sensor/test_sigfox.py @@ -0,0 +1,68 @@ +"""Tests for the sigfox sensor.""" +import re +import requests_mock +import unittest + +from homeassistant.components.sensor.sigfox import ( + API_URL, CONF_API_LOGIN, CONF_API_PASSWORD) +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + +TEST_API_LOGIN = 'foo' +TEST_API_PASSWORD = 'ebcd1234' + +VALID_CONFIG = { + 'sensor': { + 'platform': 'sigfox', + CONF_API_LOGIN: TEST_API_LOGIN, + CONF_API_PASSWORD: TEST_API_PASSWORD}} + +VALID_MESSAGE = """ +{"data":[{ +"time":1521879720, +"data":"7061796c6f6164", +"rinfos":[{"lat":"0.0","lng":"0.0"}], +"snr":"50.0"}]} +""" + + +class TestSigfoxSensor(unittest.TestCase): + """Test the sigfox platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_invalid_credentials(self): + """Test for invalid credentials.""" + with requests_mock.Mocker() as mock_req: + url = re.compile(API_URL + 'devicetypes') + mock_req.get(url, text='{}', status_code=401) + self.assertTrue( + setup_component(self.hass, 'sensor', VALID_CONFIG)) + assert len(self.hass.states.entity_ids()) == 0 + + def test_valid_credentials(self): + """Test for valid credentials.""" + with requests_mock.Mocker() as mock_req: + url1 = re.compile(API_URL + 'devicetypes') + mock_req.get(url1, text='{"data":[{"id":"fake_type"}]}', + status_code=200) + + url2 = re.compile(API_URL + 'devicetypes/fake_type/devices') + mock_req.get(url2, text='{"data":[{"id":"fake_id"}]}') + + url3 = re.compile(API_URL + 'devices/fake_id/messages*') + mock_req.get(url3, text=VALID_MESSAGE) + + self.assertTrue( + setup_component(self.hass, 'sensor', VALID_CONFIG)) + + assert len(self.hass.states.entity_ids()) == 1 + state = self.hass.states.get('sensor.sigfox_fake_id') + assert state.state == 'payload' + assert state.attributes.get('snr') == '50.0' diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index b05fc90bfe4..6861d3a5070 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -267,3 +267,40 @@ class TestTemplateSensor: self.hass.block_till_done() assert self.hass.states.all() == [] + + def test_setup_invalid_device_class(self): + """Test setup with invalid device_class.""" + with assert_setup_component(0): + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'value_template': '{{ foo }}', + 'device_class': 'foobarnotreal', + }, + }, + } + }) + + def test_setup_valid_device_class(self): + """Test setup with valid device_class.""" + with assert_setup_component(1): + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'test1': { + 'value_template': '{{ foo }}', + 'device_class': 'temperature', + }, + 'test2': {'value_template': '{{ foo }}'}, + } + } + }) + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test1') + assert state.attributes['device_class'] == 'temperature' + state = self.hass.states.get('sensor.test2') + assert 'device_class' not in state.attributes diff --git a/tests/components/sensor/test_wsdot.py b/tests/components/sensor/test_wsdot.py index ee2cec3bb2a..8eb542b2b68 100644 --- a/tests/components/sensor/test_wsdot.py +++ b/tests/components/sensor/test_wsdot.py @@ -1,17 +1,16 @@ """The tests for the WSDOT platform.""" +from datetime import datetime, timedelta, timezone import re import unittest -from datetime import timedelta, datetime, timezone import requests_mock +from tests.common import get_test_home_assistant, load_fixture from homeassistant.components.sensor import wsdot from homeassistant.components.sensor.wsdot import ( - WashingtonStateTravelTimeSensor, ATTR_DESCRIPTION, - ATTR_TIME_UPDATED, CONF_API_KEY, CONF_NAME, - CONF_ID, CONF_TRAVEL_TIMES, SCAN_INTERVAL) + ATTR_DESCRIPTION, ATTR_TIME_UPDATED, CONF_API_KEY, CONF_ID, CONF_NAME, + CONF_TRAVEL_TIMES, RESOURCE, SCAN_INTERVAL) from homeassistant.setup import setup_component -from tests.common import load_fixture, get_test_home_assistant class TestWSDOT(unittest.TestCase): @@ -50,7 +49,7 @@ class TestWSDOT(unittest.TestCase): @requests_mock.Mocker() def test_setup(self, mock_req): """Test for operational WSDOT sensor with proper attributes.""" - uri = re.compile(WashingtonStateTravelTimeSensor.RESOURCE + '*') + uri = re.compile(RESOURCE + '*') mock_req.get(uri, text=load_fixture('wsdot.json')) wsdot.setup_platform(self.hass, self.config, self.add_entities) self.assertEqual(len(self.entities), 1) diff --git a/tests/components/sensor/test_wunderground.py b/tests/components/sensor/test_wunderground.py index 27047ba0ad0..3f490b4ab12 100644 --- a/tests/components/sensor/test_wunderground.py +++ b/tests/components/sensor/test_wunderground.py @@ -143,3 +143,43 @@ def test_invalid_data(hass, aioclient_mock): for condition in VALID_CONFIG['monitored_conditions']: state = hass.states.get('sensor.pws_' + condition) assert state.state == STATE_UNKNOWN + + +async def test_entity_id_with_multiple_stations(hass, aioclient_mock): + """Test not generating duplicate entity ids with multiple stations.""" + aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json')) + aioclient_mock.get(PWS_URL, text=load_fixture('wunderground-valid.json')) + + config = [ + VALID_CONFIG, + {**VALID_CONFIG_PWS, 'entity_namespace': 'hi'} + ] + await async_setup_component(hass, 'sensor', {'sensor': config}) + await hass.async_block_till_done() + + state = hass.states.get('sensor.pws_weather') + assert state is not None + assert state.state == 'Clear' + + state = hass.states.get('sensor.hi_pws_weather') + assert state is not None + assert state.state == 'Clear' + + +async def test_fails_because_of_unique_id(hass, aioclient_mock): + """Test same config twice fails because of unique_id.""" + aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json')) + aioclient_mock.get(PWS_URL, text=load_fixture('wunderground-valid.json')) + + config = [ + VALID_CONFIG, + {**VALID_CONFIG, 'entity_namespace': 'hi'}, + VALID_CONFIG_PWS + ] + await async_setup_component(hass, 'sensor', {'sensor': config}) + await hass.async_block_till_done() + + states = hass.states.async_all() + expected = len(VALID_CONFIG['monitored_conditions']) + \ + len(VALID_CONFIG_PWS['monitored_conditions']) + assert len(states) == expected diff --git a/tests/components/sensor/test_yweather.py b/tests/components/sensor/test_yweather.py index 88b94906a35..aeee47bfa80 100644 --- a/tests/components/sensor/test_yweather.py +++ b/tests/components/sensor/test_yweather.py @@ -162,6 +162,8 @@ class TestWeather(unittest.TestCase): state = self.hass.states.get('sensor.yweather_condition') assert state is not None self.assertEqual(state.state, 'Mostly Cloudy') + self.assertEqual(state.attributes.get('condition_code'), + '28') self.assertEqual(state.attributes.get('friendly_name'), 'Yweather Condition') diff --git a/tests/components/switch/test_flux.py b/tests/components/switch/test_flux.py index a1e600860f9..61e665f265c 100644 --- a/tests/components/switch/test_flux.py +++ b/tests/components/switch/test_flux.py @@ -71,7 +71,7 @@ class TestSwitchFlux(unittest.TestCase): def test_flux_when_switch_is_off(self): """Test the flux switch when it is off.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -113,7 +113,7 @@ class TestSwitchFlux(unittest.TestCase): def test_flux_before_sunrise(self): """Test the flux switch before sunrise.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -154,13 +154,13 @@ class TestSwitchFlux(unittest.TestCase): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 112) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.606, 0.379]) # pylint: disable=invalid-name def test_flux_after_sunrise_before_sunset(self): """Test the flux switch after sunrise and before sunset.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -201,13 +201,13 @@ class TestSwitchFlux(unittest.TestCase): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 180) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.431, 0.38]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 173) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.439, 0.37]) # pylint: disable=invalid-name def test_flux_after_sunset_before_stop(self): """Test the flux switch after sunset and before stop.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -249,13 +249,13 @@ class TestSwitchFlux(unittest.TestCase): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 153) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.496, 0.397]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 146) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.506, 0.385]) # pylint: disable=invalid-name def test_flux_after_stop_before_sunrise(self): """Test the flux switch after stop and before sunrise.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -296,13 +296,13 @@ class TestSwitchFlux(unittest.TestCase): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 112) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.606, 0.379]) # pylint: disable=invalid-name def test_flux_with_custom_start_stop_times(self): """Test the flux with custom start and stop times.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -345,15 +345,15 @@ class TestSwitchFlux(unittest.TestCase): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 154) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.494, 0.397]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 147) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.504, 0.385]) def test_flux_before_sunrise_stop_next_day(self): """Test the flux switch before sunrise. This test has the stop_time on the next day (after midnight). """ - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -395,8 +395,8 @@ class TestSwitchFlux(unittest.TestCase): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 112) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.606, 0.379]) # pylint: disable=invalid-name def test_flux_after_sunrise_before_sunset_stop_next_day(self): @@ -405,7 +405,7 @@ class TestSwitchFlux(unittest.TestCase): This test has the stop_time on the next day (after midnight). """ - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -447,8 +447,8 @@ class TestSwitchFlux(unittest.TestCase): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 180) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.431, 0.38]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 173) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.439, 0.37]) # pylint: disable=invalid-name def test_flux_after_sunset_before_midnight_stop_next_day(self): @@ -456,7 +456,7 @@ class TestSwitchFlux(unittest.TestCase): This test has the stop_time on the next day (after midnight). """ - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -498,8 +498,8 @@ class TestSwitchFlux(unittest.TestCase): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 126) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.574, 0.401]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.588, 0.386]) # pylint: disable=invalid-name def test_flux_after_sunset_after_midnight_stop_next_day(self): @@ -507,7 +507,7 @@ class TestSwitchFlux(unittest.TestCase): This test has the stop_time on the next day (after midnight). """ - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -549,8 +549,8 @@ class TestSwitchFlux(unittest.TestCase): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 122) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.586, 0.397]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 114) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.601, 0.382]) # pylint: disable=invalid-name def test_flux_after_stop_before_sunrise_stop_next_day(self): @@ -558,7 +558,7 @@ class TestSwitchFlux(unittest.TestCase): This test has the stop_time on the next day (after midnight). """ - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -600,13 +600,13 @@ class TestSwitchFlux(unittest.TestCase): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 112) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.606, 0.379]) # pylint: disable=invalid-name def test_flux_with_custom_colortemps(self): """Test the flux with custom start and stop colortemps.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -650,13 +650,13 @@ class TestSwitchFlux(unittest.TestCase): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 167) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.461, 0.389]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 159) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.469, 0.378]) # pylint: disable=invalid-name def test_flux_with_custom_brightness(self): """Test the flux with custom start and stop colortemps.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -700,11 +700,11 @@ class TestSwitchFlux(unittest.TestCase): self.hass.block_till_done() call = turn_on_calls[-1] self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 255) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.496, 0.397]) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.506, 0.385]) def test_flux_with_multiple_lights(self): """Test the flux switch with multiple light entities.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -762,18 +762,18 @@ class TestSwitchFlux(unittest.TestCase): fire_time_changed(self.hass, test_time) self.hass.block_till_done() call = turn_on_calls[-1] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 171) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.452, 0.386]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 163) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.46, 0.376]) call = turn_on_calls[-2] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 171) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.452, 0.386]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 163) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.46, 0.376]) call = turn_on_calls[-3] - self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 171) - self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.452, 0.386]) + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 163) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.46, 0.376]) def test_flux_with_mired(self): """Test the flux switch´s mode mired.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, @@ -818,7 +818,7 @@ class TestSwitchFlux(unittest.TestCase): def test_flux_with_rgb(self): """Test the flux switch´s mode rgb.""" - platform = loader.get_component('light.test') + platform = loader.get_component(self.hass, 'light.test') platform.init() self.assertTrue( setup_component(self.hass, light.DOMAIN, diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index 090e3c74bf1..d679aa2c827 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -17,7 +17,7 @@ class TestSwitch(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - platform = loader.get_component('switch.test') + platform = loader.get_component(self.hass, 'switch.test') platform.init() # Switch 1 is ON, switch 2 is OFF self.switch_1, self.switch_2, self.switch_3 = \ @@ -79,10 +79,10 @@ class TestSwitch(unittest.TestCase): def test_setup_two_platforms(self): """Test with bad configuration.""" # Test if switch component returns 0 switches - test_platform = loader.get_component('switch.test') + test_platform = loader.get_component(self.hass, 'switch.test') test_platform.init(True) - loader.set_component('switch.test2', test_platform) + loader.set_component(self.hass, 'switch.test2', test_platform) test_platform.init(False) self.assertTrue(setup_component( diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index f79d0706321..31f9a729c53 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -1,12 +1,14 @@ """The tests for the MQTT switch platform.""" import unittest +from unittest.mock import patch from homeassistant.setup import setup_component from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNAVAILABLE,\ ATTR_ASSUMED_STATE +import homeassistant.core as ha import homeassistant.components.switch as switch from tests.common import ( - mock_mqtt_component, fire_mqtt_message, get_test_home_assistant) + mock_mqtt_component, fire_mqtt_message, get_test_home_assistant, mock_coro) class TestSwitchMQTT(unittest.TestCase): @@ -18,7 +20,7 @@ class TestSwitchMQTT(unittest.TestCase): self.mock_publish = mock_mqtt_component(self.hass) def tearDown(self): # pylint: disable=invalid-name - """"Stop everything that was started.""" + """Stop everything that was started.""" self.hass.stop() def test_controlling_state_via_topic(self): @@ -52,19 +54,23 @@ class TestSwitchMQTT(unittest.TestCase): def test_sending_mqtt_commands_and_optimistic(self): """Test the sending MQTT commands in optimistic mode.""" - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'command_topic': 'command-topic', - 'payload_on': 'beer on', - 'payload_off': 'beer off', - 'qos': '2' - } - }) + fake_state = ha.State('switch.test', 'on') + + with patch('homeassistant.components.switch.mqtt.async_get_last_state', + return_value=mock_coro(fake_state)): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'command-topic', + 'payload_on': 'beer on', + 'payload_off': 'beer off', + 'qos': '2' + } + }) state = self.hass.states.get('switch.test') - self.assertEqual(STATE_OFF, state.state) + self.assertEqual(STATE_ON, state.state) self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) switch.turn_on(self.hass, 'switch.test') @@ -242,3 +248,26 @@ class TestSwitchMQTT(unittest.TestCase): state = self.hass.states.get('switch.test') self.assertEqual(STATE_ON, state.state) + + def test_unique_id(self): + """Test unique id option only creates one switch per unique_id.""" + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: [{ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'command-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }, { + 'platform': 'mqtt', + 'name': 'Test 2', + 'state_topic': 'test-topic', + 'command_topic': 'command-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }] + }) + + fire_mqtt_message(self.hass, 'test-topic', 'payload') + self.hass.block_till_done() + assert len(self.hass.states.async_entity_ids()) == 2 + # all switches group is 1, unique id created is 1 diff --git a/tests/components/switch/test_template.py b/tests/components/switch/test_template.py index 7456ae11a0d..8f7bbda8e98 100644 --- a/tests/components/switch/test_template.py +++ b/tests/components/switch/test_template.py @@ -32,7 +32,7 @@ class TestTemplateSwitch: self.hass.stop() def test_template_state_text(self): - """"Test the state text of a template.""" + """Test the state text of a template.""" with assert_setup_component(1, 'switch'): assert setup.setup_component(self.hass, 'switch', { 'switch': { diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 69b9bfa69de..f53010ef27f 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -2,19 +2,22 @@ # pylint: disable=protected-access import asyncio import json +from unittest.mock import patch +from aiohttp import web import pytest from homeassistant import const +from homeassistant.bootstrap import DATA_LOGGING import homeassistant.core as ha from homeassistant.setup import async_setup_component @pytest.fixture -def mock_api_client(hass, test_client): +def mock_api_client(hass, aiohttp_client): """Start the Hass HTTP component.""" hass.loop.run_until_complete(async_setup_component(hass, 'api', {})) - return hass.loop.run_until_complete(test_client(hass.http.app)) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine @@ -398,3 +401,31 @@ def _stream_next_event(stream): def _listen_count(hass): """Return number of event listeners.""" return sum(hass.bus.async_listeners().values()) + + +async def test_api_error_log(hass, aiohttp_client): + """Test if we can fetch the error log.""" + hass.data[DATA_LOGGING] = '/some/path' + await async_setup_component(hass, 'api', { + 'http': { + 'api_password': 'yolo' + } + }) + client = await aiohttp_client(hass.http.app) + + resp = await client.get(const.URL_API_ERROR_LOG) + # Verufy auth required + assert resp.status == 401 + + with patch( + 'aiohttp.web.FileResponse', + return_value=web.Response(status=200, text='Hello') + ) as mock_file: + resp = await client.get(const.URL_API_ERROR_LOG, headers={ + 'x-ha-access': 'yolo' + }) + + assert len(mock_file.mock_calls) == 1 + assert mock_file.mock_calls[0][1][0] == hass.data[DATA_LOGGING] + assert resp.status == 200 + assert await resp.text() == 'Hello' diff --git a/tests/components/test_canary.py b/tests/components/test_canary.py index 2c496c26e11..310f3be9f05 100644 --- a/tests/components/test_canary.py +++ b/tests/components/test_canary.py @@ -8,12 +8,16 @@ from tests.common import ( get_test_home_assistant) -def mock_device(device_id, name, is_online=True): +def mock_device(device_id, name, is_online=True, device_type_name=None): """Mock Canary Device class.""" device = MagicMock() type(device).device_id = PropertyMock(return_value=device_id) type(device).name = PropertyMock(return_value=name) type(device).is_online = PropertyMock(return_value=is_online) + type(device).device_type = PropertyMock(return_value={ + "id": 1, + "name": device_type_name, + }) return device diff --git a/tests/components/test_config_entry_example.py b/tests/components/test_config_entry_example.py deleted file mode 100644 index 31084384c31..00000000000 --- a/tests/components/test_config_entry_example.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Test the config entry example component.""" -import asyncio - -from homeassistant import config_entries - - -@asyncio.coroutine -def test_flow_works(hass): - """Test that the config flow works.""" - result = yield from hass.config_entries.flow.async_init( - 'config_entry_example') - - assert result['type'] == config_entries.RESULT_TYPE_FORM - - result = yield from hass.config_entries.flow.async_configure( - result['flow_id'], { - 'object_id': 'bla' - }) - - assert result['type'] == config_entries.RESULT_TYPE_FORM - - result = yield from hass.config_entries.flow.async_configure( - result['flow_id'], { - 'name': 'Hello' - }) - - assert result['type'] == config_entries.RESULT_TYPE_CREATE_ENTRY - state = hass.states.get('config_entry_example.bla') - assert state is not None - assert state.name == 'Hello' - assert 'config_entry_example' in hass.config.components - assert len(hass.config_entries.async_entries()) == 1 - - # Test removing entry. - entry = hass.config_entries.async_entries()[0] - yield from hass.config_entries.async_remove(entry.entry_id) - state = hass.states.get('config_entry_example.bla') - assert state is None diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index 466dc57017a..6a1d5a55c47 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -1,26 +1,24 @@ """The tests for the Conversation component.""" # pylint: disable=protected-access -import asyncio - import pytest from homeassistant.setup import async_setup_component from homeassistant.components import conversation import homeassistant.components as component +from homeassistant.components.cover import (SERVICE_OPEN_COVER) from homeassistant.helpers import intent from tests.common import async_mock_intent, async_mock_service -@asyncio.coroutine -def test_calling_intent(hass): +async def test_calling_intent(hass): """Test calling an intent from a conversation.""" intents = async_mock_intent(hass, 'OrderBeer') - result = yield from component.async_setup(hass, {}) + result = await component.async_setup(hass, {}) assert result - result = yield from async_setup_component(hass, 'conversation', { + result = await async_setup_component(hass, 'conversation', { 'conversation': { 'intents': { 'OrderBeer': [ @@ -31,11 +29,11 @@ def test_calling_intent(hass): }) assert result - yield from hass.services.async_call( + await hass.services.async_call( 'conversation', 'process', { conversation.ATTR_TEXT: 'I would like the Grolsch beer' }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] @@ -45,8 +43,7 @@ def test_calling_intent(hass): assert intent.text_input == 'I would like the Grolsch beer' -@asyncio.coroutine -def test_register_before_setup(hass): +async def test_register_before_setup(hass): """Test calling an intent from a conversation.""" intents = async_mock_intent(hass, 'OrderBeer') @@ -54,7 +51,7 @@ def test_register_before_setup(hass): 'A {type} beer, please' ]) - result = yield from async_setup_component(hass, 'conversation', { + result = await async_setup_component(hass, 'conversation', { 'conversation': { 'intents': { 'OrderBeer': [ @@ -65,11 +62,11 @@ def test_register_before_setup(hass): }) assert result - yield from hass.services.async_call( + await hass.services.async_call( 'conversation', 'process', { conversation.ATTR_TEXT: 'A Grolsch beer, please' }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] @@ -78,11 +75,11 @@ def test_register_before_setup(hass): assert intent.slots == {'type': {'value': 'Grolsch'}} assert intent.text_input == 'A Grolsch beer, please' - yield from hass.services.async_call( + await hass.services.async_call( 'conversation', 'process', { conversation.ATTR_TEXT: 'I would like the Grolsch beer' }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 2 intent = intents[1] @@ -92,14 +89,14 @@ def test_register_before_setup(hass): assert intent.text_input == 'I would like the Grolsch beer' -@asyncio.coroutine -def test_http_processing_intent(hass, test_client): +async def test_http_processing_intent(hass, aiohttp_client): """Test processing intent via HTTP API.""" class TestIntentHandler(intent.IntentHandler): + """Test Intent Handler.""" + intent_type = 'OrderBeer' - @asyncio.coroutine - def async_handle(self, intent): + async def async_handle(self, intent): """Handle the intent.""" response = intent.create_response() response.async_set_speech( @@ -111,7 +108,7 @@ def test_http_processing_intent(hass, test_client): intent.async_register(hass, TestIntentHandler()) - result = yield from async_setup_component(hass, 'conversation', { + result = await async_setup_component(hass, 'conversation', { 'conversation': { 'intents': { 'OrderBeer': [ @@ -122,13 +119,13 @@ def test_http_processing_intent(hass, test_client): }) assert result - client = yield from test_client(hass.http.app) - resp = yield from client.post('/api/conversation/process', json={ + client = await aiohttp_client(hass.http.app) + resp = await client.post('/api/conversation/process', json={ 'text': 'I would like the Grolsch beer' }) assert resp.status == 200 - data = yield from resp.json() + data = await resp.json() assert data == { 'card': { @@ -145,24 +142,23 @@ def test_http_processing_intent(hass, test_client): } -@asyncio.coroutine @pytest.mark.parametrize('sentence', ('turn on kitchen', 'turn kitchen on')) -def test_turn_on_intent(hass, sentence): +async def test_turn_on_intent(hass, sentence): """Test calling the turn on intent.""" - result = yield from component.async_setup(hass, {}) + result = await component.async_setup(hass, {}) assert result - result = yield from async_setup_component(hass, 'conversation', {}) + result = await async_setup_component(hass, 'conversation', {}) assert result hass.states.async_set('light.kitchen', 'off') calls = async_mock_service(hass, 'homeassistant', 'turn_on') - yield from hass.services.async_call( + await hass.services.async_call( 'conversation', 'process', { conversation.ATTR_TEXT: sentence }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 call = calls[0] @@ -171,24 +167,49 @@ def test_turn_on_intent(hass, sentence): assert call.data == {'entity_id': 'light.kitchen'} -@asyncio.coroutine -@pytest.mark.parametrize('sentence', ('turn off kitchen', 'turn kitchen off')) -def test_turn_off_intent(hass, sentence): - """Test calling the turn on intent.""" - result = yield from component.async_setup(hass, {}) +async def test_cover_intents_loading(hass): + """Test Cover Intents Loading.""" + with pytest.raises(intent.UnknownIntent): + await intent.async_handle( + hass, 'test', 'HassOpenCover', {'name': {'value': 'garage door'}} + ) + + result = await async_setup_component(hass, 'cover', {}) assert result - result = yield from async_setup_component(hass, 'conversation', {}) + hass.states.async_set('cover.garage_door', 'closed') + calls = async_mock_service(hass, 'cover', SERVICE_OPEN_COVER) + + response = await intent.async_handle( + hass, 'test', 'HassOpenCover', {'name': {'value': 'garage door'}} + ) + await hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Opened garage door' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'cover' + assert call.service == 'open_cover' + assert call.data == {'entity_id': 'cover.garage_door'} + + +@pytest.mark.parametrize('sentence', ('turn off kitchen', 'turn kitchen off')) +async def test_turn_off_intent(hass, sentence): + """Test calling the turn on intent.""" + result = await component.async_setup(hass, {}) + assert result + + result = await async_setup_component(hass, 'conversation', {}) assert result hass.states.async_set('light.kitchen', 'on') calls = async_mock_service(hass, 'homeassistant', 'turn_off') - yield from hass.services.async_call( + await hass.services.async_call( 'conversation', 'process', { conversation.ATTR_TEXT: sentence }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 call = calls[0] @@ -197,24 +218,23 @@ def test_turn_off_intent(hass, sentence): assert call.data == {'entity_id': 'light.kitchen'} -@asyncio.coroutine @pytest.mark.parametrize('sentence', ('toggle kitchen', 'kitchen toggle')) -def test_toggle_intent(hass, sentence): +async def test_toggle_intent(hass, sentence): """Test calling the turn on intent.""" - result = yield from component.async_setup(hass, {}) + result = await component.async_setup(hass, {}) assert result - result = yield from async_setup_component(hass, 'conversation', {}) + result = await async_setup_component(hass, 'conversation', {}) assert result hass.states.async_set('light.kitchen', 'on') calls = async_mock_service(hass, 'homeassistant', 'toggle') - yield from hass.services.async_call( + await hass.services.async_call( 'conversation', 'process', { conversation.ATTR_TEXT: sentence }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(calls) == 1 call = calls[0] @@ -223,20 +243,19 @@ def test_toggle_intent(hass, sentence): assert call.data == {'entity_id': 'light.kitchen'} -@asyncio.coroutine -def test_http_api(hass, test_client): +async def test_http_api(hass, aiohttp_client): """Test the HTTP conversation API.""" - result = yield from component.async_setup(hass, {}) + result = await component.async_setup(hass, {}) assert result - result = yield from async_setup_component(hass, 'conversation', {}) + result = await async_setup_component(hass, 'conversation', {}) assert result - client = yield from test_client(hass.http.app) + client = await aiohttp_client(hass.http.app) hass.states.async_set('light.kitchen', 'off') calls = async_mock_service(hass, 'homeassistant', 'turn_on') - resp = yield from client.post('/api/conversation/process', json={ + resp = await client.post('/api/conversation/process', json={ 'text': 'Turn the kitchen on' }) assert resp.status == 200 @@ -248,23 +267,22 @@ def test_http_api(hass, test_client): assert call.data == {'entity_id': 'light.kitchen'} -@asyncio.coroutine -def test_http_api_wrong_data(hass, test_client): +async def test_http_api_wrong_data(hass, aiohttp_client): """Test the HTTP conversation API.""" - result = yield from component.async_setup(hass, {}) + result = await component.async_setup(hass, {}) assert result - result = yield from async_setup_component(hass, 'conversation', {}) + result = await async_setup_component(hass, 'conversation', {}) assert result - client = yield from test_client(hass.http.app) + client = await aiohttp_client(hass.http.app) - resp = yield from client.post('/api/conversation/process', json={ + resp = await client.post('/api/conversation/process', json={ 'text': 123 }) assert resp.status == 400 - resp = yield from client.post('/api/conversation/process', json={ + resp = await client.post('/api/conversation/process', json={ }) assert resp.status == 400 diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py index 3c73e85c4e5..a8b8a201217 100644 --- a/tests/components/test_device_sun_light_trigger.py +++ b/tests/components/test_device_sun_light_trigger.py @@ -22,12 +22,12 @@ class TestDeviceSunLightTrigger(unittest.TestCase): self.hass = get_test_home_assistant() self.scanner = loader.get_component( - 'device_tracker.test').get_scanner(None, None) + self.hass, 'device_tracker.test').get_scanner(None, None) self.scanner.reset() self.scanner.come_home('DEV1') - loader.get_component('light.test').init() + loader.get_component(self.hass, 'light.test').init() with patch( 'homeassistant.components.device_tracker.load_yaml_config_file', diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index 580d876982d..dd22c87cb18 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -5,6 +5,7 @@ from unittest.mock import patch, MagicMock import pytest +from homeassistant import data_entry_flow from homeassistant.bootstrap import async_setup_component from homeassistant.components import discovery from homeassistant.util.dt import utcnow @@ -24,7 +25,8 @@ UNKNOWN_SERVICE = 'this_service_will_never_be_supported' BASE_CONFIG = { discovery.DOMAIN: { - 'ignore': [] + 'ignore': [], + 'enable': [] } } @@ -44,13 +46,12 @@ def netdisco_mock(): yield -@asyncio.coroutine -def mock_discovery(hass, discoveries, config=BASE_CONFIG): +async def mock_discovery(hass, discoveries, config=BASE_CONFIG): """Helper to mock discoveries.""" - result = yield from async_setup_component(hass, 'discovery', config) + result = await async_setup_component(hass, 'discovery', config) assert result - yield from hass.async_start() + await hass.async_start() with patch.object(discovery, '_discover', discoveries), \ patch('homeassistant.components.discovery.async_discover', @@ -59,8 +60,8 @@ def mock_discovery(hass, discoveries, config=BASE_CONFIG): return_value=mock_coro()) as mock_platform: async_fire_time_changed(hass, utcnow()) # Work around an issue where our loop.call_soon not get caught - yield from hass.async_block_till_done() - yield from hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() return mock_discover, mock_platform @@ -154,3 +155,25 @@ def test_load_component_hassio(hass): yield from mock_discovery(hass, discover) assert mock_hassio.called + + +async def test_discover_config_flow(hass): + """Test discovery triggering a config flow.""" + discovery_info = { + 'hello': 'world' + } + + def discover(netdisco): + """Fake discovery.""" + return [('mock-service', discovery_info)] + + with patch.dict(discovery.CONFIG_ENTRY_HANDLERS, { + 'mock-service': 'mock-component'}), patch( + 'homeassistant.data_entry_flow.FlowManager.async_init') as m_init: + await mock_discovery(hass, discover) + + assert len(m_init.mock_calls) == 1 + args, kwargs = m_init.mock_calls[0][1:] + assert args == ('mock-component',) + assert kwargs['source'] == data_entry_flow.SOURCE_DISCOVERY + assert kwargs['data'] == discovery_info diff --git a/tests/components/test_feedreader.py b/tests/components/test_feedreader.py new file mode 100644 index 00000000000..c20b297017c --- /dev/null +++ b/tests/components/test_feedreader.py @@ -0,0 +1,186 @@ +"""The tests for the feedreader component.""" +import time +from datetime import datetime, timedelta + +import unittest +from genericpath import exists +from logging import getLogger +from os import remove +from unittest import mock +from unittest.mock import patch + +from homeassistant.components import feedreader +from homeassistant.components.feedreader import CONF_URLS, FeedManager, \ + StoredData, EVENT_FEEDREADER, DEFAULT_SCAN_INTERVAL, CONF_MAX_ENTRIES, \ + DEFAULT_MAX_ENTRIES +from homeassistant.const import EVENT_HOMEASSISTANT_START, CONF_SCAN_INTERVAL +from homeassistant.core import callback +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant, load_fixture + +_LOGGER = getLogger(__name__) + +URL = 'http://some.rss.local/rss_feed.xml' +VALID_CONFIG_1 = { + feedreader.DOMAIN: { + CONF_URLS: [URL] + } +} +VALID_CONFIG_2 = { + feedreader.DOMAIN: { + CONF_URLS: [URL], + CONF_SCAN_INTERVAL: 60 + } +} +VALID_CONFIG_3 = { + feedreader.DOMAIN: { + CONF_URLS: [URL], + CONF_MAX_ENTRIES: 100 + } +} + + +class TestFeedreaderComponent(unittest.TestCase): + """Test the feedreader component.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + # Delete any previously stored data + data_file = self.hass.config.path("{}.pickle".format('feedreader')) + if exists(data_file): + remove(data_file) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_one_feed(self): + """Test the general setup of this component.""" + with patch("homeassistant.components.feedreader." + "track_time_interval") as track_method: + self.assertTrue(setup_component(self.hass, feedreader.DOMAIN, + VALID_CONFIG_1)) + track_method.assert_called_once_with(self.hass, mock.ANY, + DEFAULT_SCAN_INTERVAL) + + def test_setup_scan_interval(self): + """Test the setup of this component with scan interval.""" + with patch("homeassistant.components.feedreader." + "track_time_interval") as track_method: + self.assertTrue(setup_component(self.hass, feedreader.DOMAIN, + VALID_CONFIG_2)) + track_method.assert_called_once_with(self.hass, mock.ANY, + timedelta(seconds=60)) + + def test_setup_max_entries(self): + """Test the setup of this component with max entries.""" + self.assertTrue(setup_component(self.hass, feedreader.DOMAIN, + VALID_CONFIG_3)) + + def setup_manager(self, feed_data, max_entries=DEFAULT_MAX_ENTRIES): + """Generic test setup method.""" + events = [] + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + self.hass.bus.listen(EVENT_FEEDREADER, record_event) + + # Loading raw data from fixture and plug in to data object as URL + # works since the third-party feedparser library accepts a URL + # as well as the actual data. + data_file = self.hass.config.path("{}.pickle".format( + feedreader.DOMAIN)) + storage = StoredData(data_file) + with patch("homeassistant.components.feedreader." + "track_time_interval") as track_method: + manager = FeedManager(feed_data, DEFAULT_SCAN_INTERVAL, + max_entries, self.hass, storage) + # Can't use 'assert_called_once' here because it's not available + # in Python 3.5 yet. + track_method.assert_called_once_with(self.hass, mock.ANY, + DEFAULT_SCAN_INTERVAL) + # Artificially trigger update. + self.hass.bus.fire(EVENT_HOMEASSISTANT_START) + # Collect events. + self.hass.block_till_done() + return manager, events + + def test_feed(self): + """Test simple feed with valid data.""" + feed_data = load_fixture('feedreader.xml') + manager, events = self.setup_manager(feed_data) + assert len(events) == 1 + assert events[0].data.title == "Title 1" + assert events[0].data.description == "Description 1" + assert events[0].data.link == "http://www.example.com/link/1" + assert events[0].data.id == "GUID 1" + assert datetime.fromtimestamp( + time.mktime(events[0].data.published_parsed)) == \ + datetime(2018, 4, 30, 5, 10, 0) + assert manager.last_update_successful is True + + def test_feed_updates(self): + """Test feed updates.""" + # 1. Run + feed_data = load_fixture('feedreader.xml') + manager, events = self.setup_manager(feed_data) + assert len(events) == 1 + # 2. Run + feed_data2 = load_fixture('feedreader1.xml') + # Must patch 'get_timestamp' method because the timestamp is stored + # with the URL which in these tests is the raw XML data. + with patch("homeassistant.components.feedreader.StoredData." + "get_timestamp", return_value=time.struct_time( + (2018, 4, 30, 5, 10, 0, 0, 120, 0))): + manager2, events2 = self.setup_manager(feed_data2) + assert len(events2) == 1 + # 3. Run + feed_data3 = load_fixture('feedreader1.xml') + with patch("homeassistant.components.feedreader.StoredData." + "get_timestamp", return_value=time.struct_time( + (2018, 4, 30, 5, 11, 0, 0, 120, 0))): + manager3, events3 = self.setup_manager(feed_data3) + assert len(events3) == 0 + + def test_feed_default_max_length(self): + """Test long feed beyond the default 20 entry limit.""" + feed_data = load_fixture('feedreader2.xml') + manager, events = self.setup_manager(feed_data) + assert len(events) == 20 + + def test_feed_max_length(self): + """Test long feed beyond a configured 5 entry limit.""" + feed_data = load_fixture('feedreader2.xml') + manager, events = self.setup_manager(feed_data, max_entries=5) + assert len(events) == 5 + + def test_feed_without_publication_date(self): + """Test simple feed with entry without publication date.""" + feed_data = load_fixture('feedreader3.xml') + manager, events = self.setup_manager(feed_data) + assert len(events) == 2 + + def test_feed_invalid_data(self): + """Test feed with invalid data.""" + feed_data = "INVALID DATA" + manager, events = self.setup_manager(feed_data) + assert len(events) == 0 + assert manager.last_update_successful is True + + @mock.patch('feedparser.parse', return_value=None) + def test_feed_parsing_failed(self, mock_parse): + """Test feed where parsing fails.""" + data_file = self.hass.config.path("{}.pickle".format( + feedreader.DOMAIN)) + storage = StoredData(data_file) + manager = FeedManager("FEED DATA", DEFAULT_SCAN_INTERVAL, + DEFAULT_MAX_ENTRIES, self.hass, storage) + # Artificially trigger update. + self.hass.bus.fire(EVENT_HOMEASSISTANT_START) + # Collect events. + self.hass.block_till_done() + assert manager.last_update_successful is False diff --git a/tests/components/test_folder_watcher.py b/tests/components/test_folder_watcher.py new file mode 100644 index 00000000000..b5ac9cca9d9 --- /dev/null +++ b/tests/components/test_folder_watcher.py @@ -0,0 +1,56 @@ +"""The tests for the folder_watcher component.""" +from unittest.mock import Mock, patch +import os + +from homeassistant.components import folder_watcher +from homeassistant.setup import async_setup_component +from tests.common import MockDependency + + +async def test_invalid_path_setup(hass): + """Test that an invalid path is not setup.""" + assert not await async_setup_component( + hass, folder_watcher.DOMAIN, { + folder_watcher.DOMAIN: { + folder_watcher.CONF_FOLDER: 'invalid_path' + } + }) + + +async def test_valid_path_setup(hass): + """Test that a valid path is setup.""" + cwd = os.path.join(os.path.dirname(__file__)) + hass.config.whitelist_external_dirs = set((cwd)) + with patch.object(folder_watcher, 'Watcher'): + assert await async_setup_component( + hass, folder_watcher.DOMAIN, { + folder_watcher.DOMAIN: {folder_watcher.CONF_FOLDER: cwd} + }) + + +@MockDependency('watchdog', 'events') +def test_event(mock_watchdog): + """Check that HASS events are fired correctly on watchdog event.""" + class MockPatternMatchingEventHandler: + """Mock base class for the pattern matcher event handler.""" + + def __init__(self, patterns): + pass + + mock_watchdog.events.PatternMatchingEventHandler = \ + MockPatternMatchingEventHandler + hass = Mock() + handler = folder_watcher.create_event_handler(['*'], hass) + handler.on_created(Mock( + is_directory=False, + src_path='/hello/world.txt', + event_type='created' + )) + assert hass.bus.fire.called + assert hass.bus.fire.mock_calls[0][1][0] == folder_watcher.DOMAIN + assert hass.bus.fire.mock_calls[0][1][1] == { + 'event_type': 'created', + 'path': '/hello/world.txt', + 'file': 'world.txt', + 'folder': '/hello', + } diff --git a/tests/components/test_freedns.py b/tests/components/test_freedns.py new file mode 100644 index 00000000000..b8e38e9c3a8 --- /dev/null +++ b/tests/components/test_freedns.py @@ -0,0 +1,69 @@ +"""Test the FreeDNS component.""" +import asyncio +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components import freedns +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + +ACCESS_TOKEN = 'test_token' +UPDATE_INTERVAL = freedns.DEFAULT_INTERVAL +UPDATE_URL = freedns.UPDATE_URL + + +@pytest.fixture +def setup_freedns(hass, aioclient_mock): + """Fixture that sets up FreeDNS.""" + params = {} + params[ACCESS_TOKEN] = "" + aioclient_mock.get( + UPDATE_URL, params=params, text='Successfully updated 1 domains.') + + hass.loop.run_until_complete(async_setup_component(hass, freedns.DOMAIN, { + freedns.DOMAIN: { + 'access_token': ACCESS_TOKEN, + 'update_interval': UPDATE_INTERVAL, + } + })) + + +@asyncio.coroutine +def test_setup(hass, aioclient_mock): + """Test setup works if update passes.""" + params = {} + params[ACCESS_TOKEN] = "" + aioclient_mock.get( + UPDATE_URL, params=params, text='ERROR: Address has not changed.') + + result = yield from async_setup_component(hass, freedns.DOMAIN, { + freedns.DOMAIN: { + 'access_token': ACCESS_TOKEN, + 'update_interval': UPDATE_INTERVAL, + } + }) + assert result + assert aioclient_mock.call_count == 1 + + async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL) + yield from hass.async_block_till_done() + assert aioclient_mock.call_count == 2 + + +@asyncio.coroutine +def test_setup_fails_if_wrong_token(hass, aioclient_mock): + """Test setup fails if first update fails through wrong token.""" + params = {} + params[ACCESS_TOKEN] = "" + aioclient_mock.get( + UPDATE_URL, params=params, text='ERROR: Invalid update URL (2)') + + result = yield from async_setup_component(hass, freedns.DOMAIN, { + freedns.DOMAIN: { + 'access_token': ACCESS_TOKEN, + 'update_interval': UPDATE_INTERVAL, + } + }) + assert not result + assert aioclient_mock.call_count == 1 diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index c4ade7f5c19..657497b868b 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -9,17 +9,18 @@ from homeassistant.setup import async_setup_component from homeassistant.components.frontend import ( DOMAIN, CONF_JS_VERSION, CONF_THEMES, CONF_EXTRA_HTML_URL, CONF_EXTRA_HTML_URL_ES5, DATA_PANELS) +from homeassistant.components import websocket_api as wapi @pytest.fixture -def mock_http_client(hass, test_client): +def mock_http_client(hass, aiohttp_client): """Start the Hass HTTP component.""" hass.loop.run_until_complete(async_setup_component(hass, 'frontend', {})) - return hass.loop.run_until_complete(test_client(hass.http.app)) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @pytest.fixture -def mock_http_client_with_themes(hass, test_client): +def mock_http_client_with_themes(hass, aiohttp_client): """Start the Hass HTTP component.""" hass.loop.run_until_complete(async_setup_component(hass, 'frontend', { DOMAIN: { @@ -29,11 +30,11 @@ def mock_http_client_with_themes(hass, test_client): } } }})) - return hass.loop.run_until_complete(test_client(hass.http.app)) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @pytest.fixture -def mock_http_client_with_urls(hass, test_client): +def mock_http_client_with_urls(hass, aiohttp_client): """Start the Hass HTTP component.""" hass.loop.run_until_complete(async_setup_component(hass, 'frontend', { DOMAIN: { @@ -42,7 +43,7 @@ def mock_http_client_with_urls(hass, test_client): CONF_EXTRA_HTML_URL_ES5: ["https://domain.com/my_extra_url_es5.html"] }})) - return hass.loop.run_until_complete(test_client(hass.http.app)) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine @@ -56,7 +57,7 @@ def test_frontend_and_static(mock_http_client): # Test we can retrieve frontend.js frontendjs = re.search( - r'(?P\/frontend_es5\/frontend-[A-Za-z0-9]{32}.html)', text) + r'(?P\/frontend_es5\/app-[A-Za-z0-9]{32}.js)', text) assert frontendjs is not None resp = yield from mock_http_client.get(frontendjs.groups(0)[0]) @@ -189,3 +190,26 @@ def test_panel_without_path(hass): 'test_component', 'nonexistant_file') yield from async_setup_component(hass, 'frontend', {}) assert 'test_component' not in hass.data[DATA_PANELS] + + +async def test_get_panels(hass, hass_ws_client): + """Test get_panels command.""" + await async_setup_component(hass, 'frontend') + await hass.components.frontend.async_register_built_in_panel( + 'map', 'Map', 'mdi:account-location') + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'get_panels', + }) + + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == wapi.TYPE_RESULT + assert msg['success'] + assert msg['result']['map']['component_name'] == 'map' + assert msg['result']['map']['url_path'] == 'map' + assert msg['result']['map']['icon'] == 'mdi:account-location' + assert msg['result']['map']['title'] == 'Map' diff --git a/tests/components/test_google.py b/tests/components/test_google.py index fd45cfc59a9..0ee066fcfee 100644 --- a/tests/components/test_google.py +++ b/tests/components/test_google.py @@ -58,6 +58,7 @@ class TestGoogle(unittest.TestCase): 'device_id': 'we_are_we_are_a_test_calendar', 'name': 'We are, we are, a... Test Calendar', 'track': True, + 'ignore_availability': True, }] }) diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 4a759e7e0ac..5d909492380 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -4,7 +4,7 @@ from datetime import timedelta import unittest from unittest.mock import patch, sentinel -from homeassistant.setup import setup_component +from homeassistant.setup import setup_component, async_setup_component import homeassistant.core as ha import homeassistant.util.dt as dt_util from homeassistant.components import history, recorder @@ -131,6 +131,39 @@ class TestComponentHistory(unittest.TestCase): self.assertEqual(states, hist[entity_id]) + def test_get_last_state_changes(self): + """Test number of state changes.""" + self.init_recorder() + entity_id = 'sensor.test' + + def set_state(state): + """Set the state.""" + self.hass.states.set(entity_id, state) + self.wait_recording_done() + return self.hass.states.get(entity_id) + + start = dt_util.utcnow() - timedelta(minutes=2) + point = start + timedelta(minutes=1) + point2 = point + timedelta(minutes=1) + + with patch('homeassistant.components.recorder.dt_util.utcnow', + return_value=start): + set_state('1') + + states = [] + with patch('homeassistant.components.recorder.dt_util.utcnow', + return_value=point): + states.append(set_state('2')) + + with patch('homeassistant.components.recorder.dt_util.utcnow', + return_value=point2): + states.append(set_state('3')) + + hist = history.get_last_state_changes( + self.hass, 2, entity_id) + + self.assertEqual(states, hist[entity_id]) + def test_get_significant_states(self): """Test that only significant states are returned. @@ -481,3 +514,15 @@ class TestComponentHistory(unittest.TestCase): set_state(therm, 22, attributes={'current_temperature': 21, 'hidden': True}) return zero, four, states + + +async def test_fetch_period_api(hass, aiohttp_client): + """Test the fetch period view for history.""" + await hass.async_add_job(init_recorder_component, hass) + await async_setup_component(hass, 'history', {}) + await hass.components.recorder.wait_connection_ready() + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + client = await aiohttp_client(hass.http.app) + response = await client.get( + '/api/history/period/{}'.format(dt_util.utcnow().isoformat())) + assert response.status == 200 diff --git a/tests/components/test_hue.py b/tests/components/test_hue.py deleted file mode 100644 index 78f8b573666..00000000000 --- a/tests/components/test_hue.py +++ /dev/null @@ -1,588 +0,0 @@ -"""Generic Philips Hue component tests.""" -import asyncio -import logging -import unittest -from unittest.mock import call, MagicMock, patch - -import aiohue -import pytest -import voluptuous as vol - -from homeassistant.components import configurator, hue -from homeassistant.const import CONF_FILENAME, CONF_HOST -from homeassistant.setup import setup_component, async_setup_component - -from tests.common import ( - assert_setup_component, get_test_home_assistant, get_test_config_dir, - MockDependency, MockConfigEntry, mock_coro -) - -_LOGGER = logging.getLogger(__name__) - - -class TestSetup(unittest.TestCase): - """Test the Hue component.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.skip_teardown_stop = False - - def tearDown(self): - """Stop everything that was started.""" - if not self.skip_teardown_stop: - self.hass.stop() - - @MockDependency('phue') - def test_setup_no_domain(self, mock_phue): - """If it's not in the config we won't even try.""" - with assert_setup_component(0): - self.assertTrue(setup_component( - self.hass, hue.DOMAIN, {})) - mock_phue.Bridge.assert_not_called() - self.assertEqual({}, self.hass.data[hue.DOMAIN]) - - @MockDependency('phue') - def test_setup_with_host(self, mock_phue): - """Host specified in the config file.""" - mock_bridge = mock_phue.Bridge - - with assert_setup_component(1): - with patch('homeassistant.helpers.discovery.load_platform') \ - as mock_load: - self.assertTrue(setup_component( - self.hass, hue.DOMAIN, - {hue.DOMAIN: {hue.CONF_BRIDGES: [ - {CONF_HOST: 'localhost'}]}})) - - mock_bridge.assert_called_once_with( - 'localhost', - config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) - mock_load.assert_called_once_with( - self.hass, 'light', hue.DOMAIN, - {'bridge_id': '127.0.0.1'}) - - self.assertTrue(hue.DOMAIN in self.hass.data) - self.assertEqual(1, len(self.hass.data[hue.DOMAIN])) - - @MockDependency('phue') - def test_setup_with_phue_conf(self, mock_phue): - """No host in the config file, but one is cached in phue.conf.""" - mock_bridge = mock_phue.Bridge - - with assert_setup_component(1): - with patch( - 'homeassistant.components.hue._find_host_from_config', - return_value='localhost'): - with patch('homeassistant.helpers.discovery.load_platform') \ - as mock_load: - self.assertTrue(setup_component( - self.hass, hue.DOMAIN, - {hue.DOMAIN: {hue.CONF_BRIDGES: [ - {CONF_FILENAME: 'phue.conf'}]}})) - - mock_bridge.assert_called_once_with( - 'localhost', - config_file_path=get_test_config_dir( - hue.PHUE_CONFIG_FILE)) - mock_load.assert_called_once_with( - self.hass, 'light', hue.DOMAIN, - {'bridge_id': '127.0.0.1'}) - - self.assertTrue(hue.DOMAIN in self.hass.data) - self.assertEqual(1, len(self.hass.data[hue.DOMAIN])) - - @MockDependency('phue') - def test_setup_with_multiple_hosts(self, mock_phue): - """Multiple hosts specified in the config file.""" - mock_bridge = mock_phue.Bridge - - with assert_setup_component(1): - with patch('homeassistant.helpers.discovery.load_platform') \ - as mock_load: - self.assertTrue(setup_component( - self.hass, hue.DOMAIN, - {hue.DOMAIN: {hue.CONF_BRIDGES: [ - {CONF_HOST: 'localhost'}, - {CONF_HOST: '192.168.0.1'}]}})) - - mock_bridge.assert_has_calls([ - call( - 'localhost', - config_file_path=get_test_config_dir( - hue.PHUE_CONFIG_FILE)), - call( - '192.168.0.1', - config_file_path=get_test_config_dir( - hue.PHUE_CONFIG_FILE))]) - mock_load.mock_bridge.assert_not_called() - mock_load.assert_has_calls([ - call( - self.hass, 'light', hue.DOMAIN, - {'bridge_id': '127.0.0.1'}), - call( - self.hass, 'light', hue.DOMAIN, - {'bridge_id': '192.168.0.1'}), - ], any_order=True) - - self.assertTrue(hue.DOMAIN in self.hass.data) - self.assertEqual(2, len(self.hass.data[hue.DOMAIN])) - - @MockDependency('phue') - def test_bridge_discovered(self, mock_phue): - """Bridge discovery.""" - mock_bridge = mock_phue.Bridge - mock_service = MagicMock() - discovery_info = {'host': '192.168.0.10', 'serial': 'foobar'} - - with patch('homeassistant.helpers.discovery.load_platform') \ - as mock_load: - self.assertTrue(setup_component( - self.hass, hue.DOMAIN, {})) - hue.bridge_discovered(self.hass, mock_service, discovery_info) - - mock_bridge.assert_called_once_with( - '192.168.0.10', - config_file_path=get_test_config_dir('phue-foobar.conf')) - mock_load.assert_called_once_with( - self.hass, 'light', hue.DOMAIN, - {'bridge_id': '192.168.0.10'}) - - self.assertTrue(hue.DOMAIN in self.hass.data) - self.assertEqual(1, len(self.hass.data[hue.DOMAIN])) - - @MockDependency('phue') - def test_bridge_configure_and_discovered(self, mock_phue): - """Bridge is in the config file, then we discover it.""" - mock_bridge = mock_phue.Bridge - mock_service = MagicMock() - discovery_info = {'host': '192.168.1.10', 'serial': 'foobar'} - - with assert_setup_component(1): - with patch('homeassistant.helpers.discovery.load_platform') \ - as mock_load: - # First we set up the component from config - self.assertTrue(setup_component( - self.hass, hue.DOMAIN, - {hue.DOMAIN: {hue.CONF_BRIDGES: [ - {CONF_HOST: '192.168.1.10'}]}})) - - mock_bridge.assert_called_once_with( - '192.168.1.10', - config_file_path=get_test_config_dir( - hue.PHUE_CONFIG_FILE)) - calls_to_mock_load = [ - call( - self.hass, 'light', hue.DOMAIN, - {'bridge_id': '192.168.1.10'}), - ] - mock_load.assert_has_calls(calls_to_mock_load) - - self.assertTrue(hue.DOMAIN in self.hass.data) - self.assertEqual(1, len(self.hass.data[hue.DOMAIN])) - - # Then we discover the same bridge - hue.bridge_discovered(self.hass, mock_service, discovery_info) - - # No additional calls - mock_bridge.assert_called_once_with( - '192.168.1.10', - config_file_path=get_test_config_dir( - hue.PHUE_CONFIG_FILE)) - mock_load.assert_has_calls(calls_to_mock_load) - - # Still only one - self.assertTrue(hue.DOMAIN in self.hass.data) - self.assertEqual(1, len(self.hass.data[hue.DOMAIN])) - - -class TestHueBridge(unittest.TestCase): - """Test the HueBridge class.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.data[hue.DOMAIN] = {} - self.skip_teardown_stop = False - - def tearDown(self): - """Stop everything that was started.""" - if not self.skip_teardown_stop: - self.hass.stop() - - @MockDependency('phue') - def test_setup_bridge_connection_refused(self, mock_phue): - """Test a registration failed with a connection refused exception.""" - mock_bridge = mock_phue.Bridge - mock_bridge.side_effect = ConnectionRefusedError() - - bridge = hue.HueBridge( - 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) - bridge.setup() - self.assertFalse(bridge.configured) - self.assertTrue(bridge.config_request_id is None) - - mock_bridge.assert_called_once_with( - 'localhost', - config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) - - @MockDependency('phue') - def test_setup_bridge_registration_exception(self, mock_phue): - """Test a registration failed with an exception.""" - mock_bridge = mock_phue.Bridge - mock_phue.PhueRegistrationException = Exception - mock_bridge.side_effect = mock_phue.PhueRegistrationException(1, 2) - - bridge = hue.HueBridge( - 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) - bridge.setup() - self.assertFalse(bridge.configured) - self.assertFalse(bridge.config_request_id is None) - self.assertTrue(isinstance(bridge.config_request_id, str)) - - mock_bridge.assert_called_once_with( - 'localhost', - config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) - - @MockDependency('phue') - def test_setup_bridge_registration_succeeds(self, mock_phue): - """Test a registration success sequence.""" - mock_bridge = mock_phue.Bridge - mock_phue.PhueRegistrationException = Exception - mock_bridge.side_effect = [ - # First call, raise because not registered - mock_phue.PhueRegistrationException(1, 2), - # Second call, registration is done - None, - ] - - bridge = hue.HueBridge( - 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) - bridge.setup() - self.assertFalse(bridge.configured) - self.assertFalse(bridge.config_request_id is None) - - # Simulate the user confirming the registration - self.hass.services.call( - configurator.DOMAIN, configurator.SERVICE_CONFIGURE, - {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) - - self.hass.block_till_done() - self.assertTrue(bridge.configured) - self.assertTrue(bridge.config_request_id is None) - - # We should see a total of two identical calls - args = call( - 'localhost', - config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) - mock_bridge.assert_has_calls([args, args]) - - # Make sure the request is done - self.assertEqual(1, len(self.hass.states.all())) - self.assertEqual('configured', self.hass.states.all()[0].state) - - @MockDependency('phue') - def test_setup_bridge_registration_fails(self, mock_phue): - """ - Test a registration failure sequence. - - This may happen when we start the registration process, the user - responds to the request but the bridge has become unreachable. - """ - mock_bridge = mock_phue.Bridge - mock_phue.PhueRegistrationException = Exception - mock_bridge.side_effect = [ - # First call, raise because not registered - mock_phue.PhueRegistrationException(1, 2), - # Second call, the bridge has gone away - ConnectionRefusedError(), - ] - - bridge = hue.HueBridge( - 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) - bridge.setup() - self.assertFalse(bridge.configured) - self.assertFalse(bridge.config_request_id is None) - - # Simulate the user confirming the registration - self.hass.services.call( - configurator.DOMAIN, configurator.SERVICE_CONFIGURE, - {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) - - self.hass.block_till_done() - self.assertFalse(bridge.configured) - self.assertFalse(bridge.config_request_id is None) - - # We should see a total of two identical calls - args = call( - 'localhost', - config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) - mock_bridge.assert_has_calls([args, args]) - - # The request should still be pending - self.assertEqual(1, len(self.hass.states.all())) - self.assertEqual('configure', self.hass.states.all()[0].state) - - @MockDependency('phue') - def test_setup_bridge_registration_retry(self, mock_phue): - """ - Test a registration retry sequence. - - This may happen when we start the registration process, the user - responds to the request but we fail to confirm it with the bridge. - """ - mock_bridge = mock_phue.Bridge - mock_phue.PhueRegistrationException = Exception - mock_bridge.side_effect = [ - # First call, raise because not registered - mock_phue.PhueRegistrationException(1, 2), - # Second call, for whatever reason authentication fails - mock_phue.PhueRegistrationException(1, 2), - ] - - bridge = hue.HueBridge( - 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) - bridge.setup() - self.assertFalse(bridge.configured) - self.assertFalse(bridge.config_request_id is None) - - # Simulate the user confirming the registration - self.hass.services.call( - configurator.DOMAIN, configurator.SERVICE_CONFIGURE, - {configurator.ATTR_CONFIGURE_ID: bridge.config_request_id}) - - self.hass.block_till_done() - self.assertFalse(bridge.configured) - self.assertFalse(bridge.config_request_id is None) - - # We should see a total of two identical calls - args = call( - 'localhost', - config_file_path=get_test_config_dir(hue.PHUE_CONFIG_FILE)) - mock_bridge.assert_has_calls([args, args]) - - # Make sure the request is done - self.assertEqual(1, len(self.hass.states.all())) - self.assertEqual('configure', self.hass.states.all()[0].state) - self.assertEqual( - 'Failed to register, please try again.', - self.hass.states.all()[0].attributes.get(configurator.ATTR_ERRORS)) - - @MockDependency('phue') - def test_hue_activate_scene(self, mock_phue): - """Test the hue_activate_scene service.""" - with patch('homeassistant.helpers.discovery.load_platform'): - bridge = hue.HueBridge('localhost', self.hass, - hue.PHUE_CONFIG_FILE, None) - bridge.setup() - - # No args - self.hass.services.call(hue.DOMAIN, hue.SERVICE_HUE_SCENE, - blocking=True) - bridge.bridge.run_scene.assert_not_called() - - # Only one arg - self.hass.services.call( - hue.DOMAIN, hue.SERVICE_HUE_SCENE, - {hue.ATTR_GROUP_NAME: 'group'}, - blocking=True) - bridge.bridge.run_scene.assert_not_called() - - self.hass.services.call( - hue.DOMAIN, hue.SERVICE_HUE_SCENE, - {hue.ATTR_SCENE_NAME: 'scene'}, - blocking=True) - bridge.bridge.run_scene.assert_not_called() - - # Both required args - self.hass.services.call( - hue.DOMAIN, hue.SERVICE_HUE_SCENE, - {hue.ATTR_GROUP_NAME: 'group', hue.ATTR_SCENE_NAME: 'scene'}, - blocking=True) - bridge.bridge.run_scene.assert_called_once_with('group', 'scene') - - -async def test_setup_no_host(hass, requests_mock): - """No host specified in any way.""" - requests_mock.get(hue.API_NUPNP, json=[]) - with MockDependency('phue') as mock_phue: - result = await async_setup_component( - hass, hue.DOMAIN, {hue.DOMAIN: {}}) - assert result - - mock_phue.Bridge.assert_not_called() - - assert hass.data[hue.DOMAIN] == {} - - -async def test_flow_works(hass, aioclient_mock): - """Test config flow .""" - aioclient_mock.get(hue.API_NUPNP, json=[ - {'internalipaddress': '1.2.3.4', 'id': 'bla'} - ]) - - flow = hue.HueFlowHandler() - flow.hass = hass - await flow.async_step_init() - - with patch('aiohue.Bridge') as mock_bridge: - def mock_constructor(host, websession): - mock_bridge.host = host - return mock_bridge - - mock_bridge.side_effect = mock_constructor - mock_bridge.username = 'username-abc' - mock_bridge.config.name = 'Mock Bridge' - mock_bridge.config.bridgeid = 'bridge-id-1234' - mock_bridge.create_user.return_value = mock_coro() - mock_bridge.initialize.return_value = mock_coro() - - result = await flow.async_step_link(user_input={}) - - assert mock_bridge.host == '1.2.3.4' - assert len(mock_bridge.create_user.mock_calls) == 1 - assert len(mock_bridge.initialize.mock_calls) == 1 - - assert result['type'] == 'create_entry' - assert result['title'] == 'Mock Bridge' - assert result['data'] == { - 'host': '1.2.3.4', - 'bridge_id': 'bridge-id-1234', - 'username': 'username-abc' - } - - -async def test_flow_no_discovered_bridges(hass, aioclient_mock): - """Test config flow discovers no bridges.""" - aioclient_mock.get(hue.API_NUPNP, json=[]) - flow = hue.HueFlowHandler() - flow.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'abort' - - -async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock): - """Test config flow discovers only already configured bridges.""" - aioclient_mock.get(hue.API_NUPNP, json=[ - {'internalipaddress': '1.2.3.4', 'id': 'bla'} - ]) - MockConfigEntry(domain='hue', data={ - 'host': '1.2.3.4' - }).add_to_hass(hass) - flow = hue.HueFlowHandler() - flow.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'abort' - - -async def test_flow_one_bridge_discovered(hass, aioclient_mock): - """Test config flow discovers one bridge.""" - aioclient_mock.get(hue.API_NUPNP, json=[ - {'internalipaddress': '1.2.3.4', 'id': 'bla'} - ]) - flow = hue.HueFlowHandler() - flow.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'form' - assert result['step_id'] == 'link' - - -async def test_flow_two_bridges_discovered(hass, aioclient_mock): - """Test config flow discovers two bridges.""" - aioclient_mock.get(hue.API_NUPNP, json=[ - {'internalipaddress': '1.2.3.4', 'id': 'bla'}, - {'internalipaddress': '5.6.7.8', 'id': 'beer'} - ]) - flow = hue.HueFlowHandler() - flow.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'form' - assert result['step_id'] == 'init' - - with pytest.raises(vol.Invalid): - assert result['data_schema']({'host': '0.0.0.0'}) - - result['data_schema']({'host': '1.2.3.4'}) - result['data_schema']({'host': '5.6.7.8'}) - - -async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock): - """Test config flow discovers two bridges.""" - aioclient_mock.get(hue.API_NUPNP, json=[ - {'internalipaddress': '1.2.3.4', 'id': 'bla'}, - {'internalipaddress': '5.6.7.8', 'id': 'beer'} - ]) - MockConfigEntry(domain='hue', data={ - 'host': '1.2.3.4' - }).add_to_hass(hass) - flow = hue.HueFlowHandler() - flow.hass = hass - - result = await flow.async_step_init() - assert result['type'] == 'form' - assert result['step_id'] == 'link' - assert flow.host == '5.6.7.8' - - -async def test_flow_timeout_discovery(hass): - """Test config flow .""" - flow = hue.HueFlowHandler() - flow.hass = hass - - with patch('aiohue.discovery.discover_nupnp', - side_effect=asyncio.TimeoutError): - result = await flow.async_step_init() - - assert result['type'] == 'abort' - - -async def test_flow_link_timeout(hass): - """Test config flow .""" - flow = hue.HueFlowHandler() - flow.hass = hass - - with patch('aiohue.Bridge.create_user', - side_effect=asyncio.TimeoutError): - result = await flow.async_step_link({}) - - assert result['type'] == 'form' - assert result['step_id'] == 'link' - assert result['errors'] == { - 'base': 'register_failed' - } - - -async def test_flow_link_button_not_pressed(hass): - """Test config flow .""" - flow = hue.HueFlowHandler() - flow.hass = hass - - with patch('aiohue.Bridge.create_user', - side_effect=aiohue.LinkButtonNotPressed): - result = await flow.async_step_link({}) - - assert result['type'] == 'form' - assert result['step_id'] == 'link' - assert result['errors'] == { - 'base': 'register_failed' - } - - -async def test_flow_link_unknown_host(hass): - """Test config flow .""" - flow = hue.HueFlowHandler() - flow.hass = hass - - with patch('aiohue.Bridge.create_user', - side_effect=aiohue.RequestError): - result = await flow.async_step_link({}) - - assert result['type'] == 'form' - assert result['step_id'] == 'link' - assert result['errors'] == { - 'base': 'register_failed' - } diff --git a/tests/components/test_influxdb.py b/tests/components/test_influxdb.py index c909a8488be..e2323aca855 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -217,7 +217,7 @@ class TestInfluxDB(unittest.TestCase): """Test the event listener for missing units.""" self._setup() - attrs = {'bignumstring': "9" * 999} + attrs = {'bignumstring': '9' * 999, 'nonumstring': 'nan'} state = mock.MagicMock( state=8, domain='fake', entity_id='fake.entity-id', object_id='entity', attributes=attrs) diff --git a/tests/components/test_init.py b/tests/components/test_init.py index 991982af9b2..c8c7e0d809b 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -1,6 +1,5 @@ """The tests for Core components.""" # pylint: disable=protected-access -import asyncio import unittest from unittest.mock import patch, Mock @@ -75,9 +74,9 @@ class TestComponentsCore(unittest.TestCase): self.hass.block_till_done() self.assertEqual(1, len(calls)) - @asyncio.coroutine @patch('homeassistant.core.ServiceRegistry.call') - def test_turn_on_to_not_block_for_domains_without_service(self, mock_call): + async def test_turn_on_to_not_block_for_domains_without_service(self, + mock_call): """Test if turn_on is blocking domain with no service.""" async_mock_service(self.hass, 'light', SERVICE_TURN_ON) @@ -88,7 +87,7 @@ class TestComponentsCore(unittest.TestCase): 'entity_id': ['light.test', 'sensor.bla', 'light.bla'] }) service = self.hass.services._services['homeassistant']['turn_on'] - yield from service.func(service_call) + await service.func(service_call) self.assertEqual(2, mock_call.call_count) self.assertEqual( @@ -130,8 +129,8 @@ class TestComponentsCore(unittest.TestCase): comps.reload_core_config(self.hass) self.hass.block_till_done() - assert 10 == self.hass.config.latitude - assert 20 == self.hass.config.longitude + assert self.hass.config.latitude == 10 + assert self.hass.config.longitude == 20 ent.schedule_update_ha_state() self.hass.block_till_done() @@ -198,19 +197,18 @@ class TestComponentsCore(unittest.TestCase): assert not mock_stop.called -@asyncio.coroutine -def test_turn_on_intent(hass): +async def test_turn_on_intent(hass): """Test HassTurnOn intent.""" - result = yield from comps.async_setup(hass, {}) + result = await comps.async_setup(hass, {}) assert result hass.states.async_set('light.test_light', 'off') calls = async_mock_service(hass, 'light', SERVICE_TURN_ON) - response = yield from intent.async_handle( + response = await intent.async_handle( hass, 'test', 'HassTurnOn', {'name': {'value': 'test light'}} ) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert response.speech['plain']['speech'] == 'Turned test light on' assert len(calls) == 1 @@ -220,19 +218,18 @@ def test_turn_on_intent(hass): assert call.data == {'entity_id': ['light.test_light']} -@asyncio.coroutine -def test_turn_off_intent(hass): +async def test_turn_off_intent(hass): """Test HassTurnOff intent.""" - result = yield from comps.async_setup(hass, {}) + result = await comps.async_setup(hass, {}) assert result hass.states.async_set('light.test_light', 'on') calls = async_mock_service(hass, 'light', SERVICE_TURN_OFF) - response = yield from intent.async_handle( + response = await intent.async_handle( hass, 'test', 'HassTurnOff', {'name': {'value': 'test light'}} ) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert response.speech['plain']['speech'] == 'Turned test light off' assert len(calls) == 1 @@ -242,19 +239,18 @@ def test_turn_off_intent(hass): assert call.data == {'entity_id': ['light.test_light']} -@asyncio.coroutine -def test_toggle_intent(hass): +async def test_toggle_intent(hass): """Test HassToggle intent.""" - result = yield from comps.async_setup(hass, {}) + result = await comps.async_setup(hass, {}) assert result hass.states.async_set('light.test_light', 'off') calls = async_mock_service(hass, 'light', SERVICE_TOGGLE) - response = yield from intent.async_handle( + response = await intent.async_handle( hass, 'test', 'HassToggle', {'name': {'value': 'test light'}} ) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert response.speech['plain']['speech'] == 'Toggled test light' assert len(calls) == 1 @@ -264,13 +260,12 @@ def test_toggle_intent(hass): assert call.data == {'entity_id': ['light.test_light']} -@asyncio.coroutine -def test_turn_on_multiple_intent(hass): +async def test_turn_on_multiple_intent(hass): """Test HassTurnOn intent with multiple similar entities. This tests that matching finds the proper entity among similar names. """ - result = yield from comps.async_setup(hass, {}) + result = await comps.async_setup(hass, {}) assert result hass.states.async_set('light.test_light', 'off') @@ -278,10 +273,10 @@ def test_turn_on_multiple_intent(hass): hass.states.async_set('light.test_lighter', 'off') calls = async_mock_service(hass, 'light', SERVICE_TURN_ON) - response = yield from intent.async_handle( + response = await intent.async_handle( hass, 'test', 'HassTurnOn', {'name': {'value': 'test lights'}} ) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert response.speech['plain']['speech'] == 'Turned test lights 2 on' assert len(calls) == 1 diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index bd10416c7a2..6c71a263afa 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -10,8 +10,8 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ATTR_HIDDEN, STATE_NOT_HOME, STATE_ON, STATE_OFF) import homeassistant.util.dt as dt_util -from homeassistant.components import logbook -from homeassistant.setup import setup_component +from homeassistant.components import logbook, recorder +from homeassistant.setup import setup_component, async_setup_component from tests.common import ( init_recorder_component, get_test_home_assistant) @@ -555,3 +555,15 @@ class TestComponentLogbook(unittest.TestCase): 'old_state': state, 'new_state': state, }, time_fired=event_time_fired) + + +async def test_logbook_view(hass, aiohttp_client): + """Test the logbook view.""" + await hass.async_add_job(init_recorder_component, hass) + await async_setup_component(hass, 'logbook', {}) + await hass.components.recorder.wait_connection_ready() + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + client = await aiohttp_client(hass.http.app) + response = await client.get( + '/api/logbook/{}'.format(dt_util.utcnow().isoformat())) + assert response.status == 200 diff --git a/tests/components/test_microsoft_face.py b/tests/components/test_microsoft_face.py index 7a047a73f47..370059a0a09 100644 --- a/tests/components/test_microsoft_face.py +++ b/tests/components/test_microsoft_face.py @@ -2,7 +2,7 @@ import asyncio from unittest.mock import patch -import homeassistant.components.microsoft_face as mf +from homeassistant.components import camera, microsoft_face as mf from homeassistant.setup import setup_component from tests.common import ( @@ -190,7 +190,7 @@ class TestMicrosoftFaceSetup(object): assert len(aioclient_mock.mock_calls) == 1 @patch('homeassistant.components.camera.async_get_image', - return_value=mock_coro(b'Test')) + return_value=mock_coro(camera.Image('image/jpeg', b'Test'))) def test_service_face(self, camera_mock, aioclient_mock): """Setup component, test person face services.""" aioclient_mock.get( diff --git a/tests/components/test_mqtt_eventstream.py b/tests/components/test_mqtt_eventstream.py index f4fc3e89ee0..48bc04d46ed 100644 --- a/tests/components/test_mqtt_eventstream.py +++ b/tests/components/test_mqtt_eventstream.py @@ -44,11 +44,11 @@ class TestMqttEventStream(object): eventstream.DOMAIN: config}) def test_setup_succeeds(self): - """"Test the success of the setup.""" + """Test the success of the setup.""" assert self.add_eventstream() def test_setup_with_pub(self): - """"Test the setup with subscription.""" + """Test the setup with subscription.""" # Should start off with no listeners for all events assert self.hass.bus.listeners.get('*') is None @@ -60,7 +60,7 @@ class TestMqttEventStream(object): @patch('homeassistant.components.mqtt.async_subscribe') def test_subscribe(self, mock_sub): - """"Test the subscription.""" + """Test the subscription.""" sub_topic = 'foo' assert self.add_eventstream(sub_topic=sub_topic) self.hass.block_till_done() @@ -71,7 +71,7 @@ class TestMqttEventStream(object): @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_sends_message(self, mock_utcnow, mock_pub): - """"Test the sending of a new message if event changed.""" + """Test the sending of a new message if event changed.""" now = dt_util.as_utc(dt_util.now()) e_id = 'fake.entity' pub_topic = 'bar' @@ -113,7 +113,7 @@ class TestMqttEventStream(object): @patch('homeassistant.components.mqtt.async_publish') def test_time_event_does_not_send_message(self, mock_pub): - """"Test the sending of a new message if time event.""" + """Test the sending of a new message if time event.""" assert self.add_eventstream(pub_topic='bar') self.hass.block_till_done() @@ -125,7 +125,7 @@ class TestMqttEventStream(object): assert not mock_pub.called def test_receiving_remote_event_fires_hass_event(self): - """"Test the receiving of the remotely fired event.""" + """Test the receiving of the remotely fired event.""" sub_topic = 'foo' assert self.add_eventstream(sub_topic=sub_topic) self.hass.block_till_done() @@ -150,7 +150,7 @@ class TestMqttEventStream(object): @patch('homeassistant.components.mqtt.async_publish') def test_ignored_event_doesnt_send_over_stream(self, mock_pub): - """"Test the ignoring of sending events if defined.""" + """Test the ignoring of sending events if defined.""" assert self.add_eventstream(pub_topic='bar', ignore_event=['state_changed']) self.hass.block_till_done() @@ -177,7 +177,7 @@ class TestMqttEventStream(object): @patch('homeassistant.components.mqtt.async_publish') def test_wrong_ignored_event_sends_over_stream(self, mock_pub): - """"Test the ignoring of sending events if defined.""" + """Test the ignoring of sending events if defined.""" assert self.add_eventstream(pub_topic='bar', ignore_event=['statee_changed']) self.hass.block_till_done() diff --git a/tests/components/test_mqtt_statestream.py b/tests/components/test_mqtt_statestream.py index 76d8e48d03a..2ed2f4487ea 100644 --- a/tests/components/test_mqtt_statestream.py +++ b/tests/components/test_mqtt_statestream.py @@ -47,17 +47,17 @@ class TestMqttStateStream(object): assert self.add_statestream() is False def test_setup_succeeds_without_attributes(self): - """"Test the success of the setup with a valid base_topic.""" + """Test the success of the setup with a valid base_topic.""" assert self.add_statestream(base_topic='pub') def test_setup_succeeds_with_attributes(self): - """"Test setup with a valid base_topic and publish_attributes.""" + """Test setup with a valid base_topic and publish_attributes.""" assert self.add_statestream(base_topic='pub', publish_attributes=True) @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_sends_message(self, mock_utcnow, mock_pub): - """"Test the sending of a new message if event changed.""" + """Test the sending of a new message if event changed.""" e_id = 'fake.entity' base_topic = 'pub' @@ -84,7 +84,7 @@ class TestMqttStateStream(object): self, mock_utcnow, mock_pub): - """"Test the sending of a message and timestamps if event changed.""" + """Test the sending of a message and timestamps if event changed.""" e_id = 'another.entity' base_topic = 'pub' @@ -118,7 +118,7 @@ class TestMqttStateStream(object): @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_attr_sends_message(self, mock_utcnow, mock_pub): - """"Test the sending of a new message if attribute changed.""" + """Test the sending of a new message if attribute changed.""" e_id = 'fake.entity' base_topic = 'pub' @@ -134,7 +134,7 @@ class TestMqttStateStream(object): test_attributes = { "testing": "YES", "list": ["a", "b", "c"], - "bool": True + "bool": False } # Set a state of an entity @@ -150,7 +150,7 @@ class TestMqttStateStream(object): 1, True), call.async_publish(self.hass, 'pub/fake/entity/list', '["a", "b", "c"]', 1, True), - call.async_publish(self.hass, 'pub/fake/entity/bool', "true", + call.async_publish(self.hass, 'pub/fake/entity/bool', "false", 1, True) ] @@ -160,7 +160,7 @@ class TestMqttStateStream(object): @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_include_domain(self, mock_utcnow, mock_pub): - """"Test that filtering on included domain works as expected.""" + """Test that filtering on included domain works as expected.""" base_topic = 'pub' incl = { @@ -198,7 +198,7 @@ class TestMqttStateStream(object): @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_include_entity(self, mock_utcnow, mock_pub): - """"Test that filtering on included entity works as expected.""" + """Test that filtering on included entity works as expected.""" base_topic = 'pub' incl = { @@ -236,7 +236,7 @@ class TestMqttStateStream(object): @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_exclude_domain(self, mock_utcnow, mock_pub): - """"Test that filtering on excluded domain works as expected.""" + """Test that filtering on excluded domain works as expected.""" base_topic = 'pub' incl = {} @@ -274,7 +274,7 @@ class TestMqttStateStream(object): @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_exclude_entity(self, mock_utcnow, mock_pub): - """"Test that filtering on excluded entity works as expected.""" + """Test that filtering on excluded entity works as expected.""" base_topic = 'pub' incl = {} @@ -313,7 +313,7 @@ class TestMqttStateStream(object): @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_exclude_domain_include_entity( self, mock_utcnow, mock_pub): - """"Test filtering with excluded domain and included entity.""" + """Test filtering with excluded domain and included entity.""" base_topic = 'pub' incl = { @@ -354,7 +354,7 @@ class TestMqttStateStream(object): @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_include_domain_exclude_entity( self, mock_utcnow, mock_pub): - """"Test filtering with included domain and excluded entity.""" + """Test filtering with included domain and excluded entity.""" base_topic = 'pub' incl = { diff --git a/tests/components/test_panel_custom.py b/tests/components/test_panel_custom.py index d33221da2a7..596aa1b3c0b 100644 --- a/tests/components/test_panel_custom.py +++ b/tests/components/test_panel_custom.py @@ -1,23 +1,11 @@ """The tests for the panel_custom component.""" -import asyncio from unittest.mock import Mock, patch -import pytest - from homeassistant import setup from homeassistant.components import frontend -from tests.common import mock_component - -@pytest.fixture(autouse=True) -def mock_frontend_loaded(hass): - """Mock frontend is loaded.""" - mock_component(hass, 'frontend') - - -@asyncio.coroutine -def test_webcomponent_custom_path_not_found(hass): +async def test_webcomponent_custom_path_not_found(hass): """Test if a web component is found in config panels dir.""" filename = 'mock.file' @@ -33,45 +21,96 @@ def test_webcomponent_custom_path_not_found(hass): } with patch('os.path.isfile', Mock(return_value=False)): - result = yield from setup.async_setup_component( + result = await setup.async_setup_component( hass, 'panel_custom', config ) assert not result assert len(hass.data.get(frontend.DATA_PANELS, {})) == 0 -@asyncio.coroutine -def test_webcomponent_custom_path(hass): +async def test_webcomponent_custom_path(hass): """Test if a web component is found in config panels dir.""" filename = 'mock.file' config = { 'panel_custom': { - 'name': 'todomvc', + 'name': 'todo-mvc', 'webcomponent_path': filename, 'sidebar_title': 'Sidebar Title', 'sidebar_icon': 'mdi:iconicon', 'url_path': 'nice_url', - 'config': 5, + 'config': { + 'hello': 'world', + } } } with patch('os.path.isfile', Mock(return_value=True)): with patch('os.access', Mock(return_value=True)): - result = yield from setup.async_setup_component( + result = await setup.async_setup_component( hass, 'panel_custom', config ) assert result panels = hass.data.get(frontend.DATA_PANELS, []) - assert len(panels) == 1 + assert panels assert 'nice_url' in panels panel = panels['nice_url'] - assert panel.config == 5 + assert panel.config == { + 'hello': 'world', + '_panel_custom': { + 'html_url': '/api/panel_custom/todo-mvc', + 'name': 'todo-mvc', + 'embed_iframe': False, + 'trust_external': False, + }, + } assert panel.frontend_url_path == 'nice_url' assert panel.sidebar_icon == 'mdi:iconicon' assert panel.sidebar_title == 'Sidebar Title' - assert panel.path == filename + + +async def test_js_webcomponent(hass): + """Test if a web component is found in config panels dir.""" + config = { + 'panel_custom': { + 'name': 'todo-mvc', + 'js_url': '/local/bla.js', + 'sidebar_title': 'Sidebar Title', + 'sidebar_icon': 'mdi:iconicon', + 'url_path': 'nice_url', + 'config': { + 'hello': 'world', + }, + 'embed_iframe': True, + 'trust_external_script': True, + } + } + + result = await setup.async_setup_component( + hass, 'panel_custom', config + ) + assert result + + panels = hass.data.get(frontend.DATA_PANELS, []) + + assert panels + assert 'nice_url' in panels + + panel = panels['nice_url'] + + assert panel.config == { + 'hello': 'world', + '_panel_custom': { + 'js_url': '/local/bla.js', + 'name': 'todo-mvc', + 'embed_iframe': True, + 'trust_external': True, + } + } + assert panel.frontend_url_path == 'nice_url' + assert panel.sidebar_icon == 'mdi:iconicon' + assert panel.sidebar_title == 'Sidebar Title' diff --git a/tests/components/test_panel_iframe.py b/tests/components/test_panel_iframe.py index 91a07511787..214eda04ad8 100644 --- a/tests/components/test_panel_iframe.py +++ b/tests/components/test_panel_iframe.py @@ -1,6 +1,5 @@ """The tests for the panel_iframe component.""" import unittest -from unittest.mock import patch from homeassistant import setup from homeassistant.components import frontend @@ -33,8 +32,6 @@ class TestPanelIframe(unittest.TestCase): 'panel_iframe': conf }) - @patch.dict('hass_frontend_es5.FINGERPRINTS', - {'iframe': 'md5md5'}) def test_correct_config(self): """Test correct config.""" assert setup.setup_component( @@ -70,7 +67,6 @@ class TestPanelIframe(unittest.TestCase): 'config': {'url': 'http://192.168.1.1'}, 'icon': 'mdi:network-wireless', 'title': 'Router', - 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'router' } @@ -79,7 +75,6 @@ class TestPanelIframe(unittest.TestCase): 'config': {'url': 'https://www.wunderground.com/us/ca/san-diego'}, 'icon': 'mdi:weather', 'title': 'Weather', - 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'weather', } @@ -88,7 +83,6 @@ class TestPanelIframe(unittest.TestCase): 'config': {'url': '/api'}, 'icon': 'mdi:weather', 'title': 'Api', - 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'api', } @@ -97,6 +91,5 @@ class TestPanelIframe(unittest.TestCase): 'config': {'url': 'ftp://some/ftp'}, 'icon': 'mdi:weather', 'title': 'FTP', - 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'ftp', } diff --git a/tests/components/test_pilight.py b/tests/components/test_pilight.py index 06ad84e7a34..24052a56839 100644 --- a/tests/components/test_pilight.py +++ b/tests/components/test_pilight.py @@ -81,7 +81,7 @@ class TestPilight(unittest.TestCase): @patch('homeassistant.components.pilight._LOGGER.error') def test_connection_failed_error(self, mock_error): - """Try to connect at 127.0.0.1:5000 with socket error.""" + """Try to connect at 127.0.0.1:5001 with socket error.""" with assert_setup_component(4): with patch('pilight.pilight.Client', side_effect=socket.error) as mock_client: @@ -93,7 +93,7 @@ class TestPilight(unittest.TestCase): @patch('homeassistant.components.pilight._LOGGER.error') def test_connection_timeout_error(self, mock_error): - """Try to connect at 127.0.0.1:5000 with socket timeout.""" + """Try to connect at 127.0.0.1:5001 with socket timeout.""" with assert_setup_component(4): with patch('pilight.pilight.Client', side_effect=socket.timeout) as mock_client: diff --git a/tests/components/test_prometheus.py b/tests/components/test_prometheus.py index 052292b015d..e336a28eb03 100644 --- a/tests/components/test_prometheus.py +++ b/tests/components/test_prometheus.py @@ -7,14 +7,14 @@ import homeassistant.components.prometheus as prometheus @pytest.fixture -def prometheus_client(loop, hass, test_client): - """Initialize a test_client with Prometheus component.""" +def prometheus_client(loop, hass, aiohttp_client): + """Initialize an aiohttp_client with Prometheus component.""" assert loop.run_until_complete(async_setup_component( hass, prometheus.DOMAIN, {}, )) - return loop.run_until_complete(test_client(hass.http.app)) + return loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine diff --git a/tests/components/test_qwikswitch.py b/tests/components/test_qwikswitch.py new file mode 100644 index 00000000000..76655f32816 --- /dev/null +++ b/tests/components/test_qwikswitch.py @@ -0,0 +1,117 @@ +"""Test qwikswitch sensors.""" +import logging + +import pytest + +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.components.qwikswitch import DOMAIN as QWIKSWITCH +from homeassistant.bootstrap import async_setup_component +from tests.test_util.aiohttp import mock_aiohttp_client + + +_LOGGER = logging.getLogger(__name__) + + +class AiohttpClientMockResponseList(list): + """Return multiple values for aiohttp Mocker. + + aoihttp mocker uses decode to fetch the next value. + """ + + def decode(self, _): + """Return next item from list.""" + try: + res = list.pop(self, 0) + _LOGGER.debug("MockResponseList popped %s: %s", res, self) + return res + except IndexError: + raise AssertionError("MockResponseList empty") + + async def wait_till_empty(self, hass): + """Wait until empty.""" + while self: + await hass.async_block_till_done() + await hass.async_block_till_done() + + +LISTEN = AiohttpClientMockResponseList() + + +@pytest.fixture +def aioclient_mock(): + """HTTP client listen and devices.""" + devices = """[ + {"id":"@000001","name":"Switch 1","type":"rel","val":"OFF", + "time":"1522777506","rssi":"51%"}, + {"id":"@000002","name":"Light 2","type":"rel","val":"ON", + "time":"1522777507","rssi":"45%"}, + {"id":"@000003","name":"Dim 3","type":"dim","val":"280c00", + "time":"1522777544","rssi":"62%"}]""" + + with mock_aiohttp_client() as mock_session: + mock_session.get("http://127.0.0.1:2020/&listen", content=LISTEN) + mock_session.get("http://127.0.0.1:2020/&device", text=devices) + yield mock_session + + +async def test_binary_sensor_device(hass, aioclient_mock): + """Test a binary sensor device.""" + config = { + 'qwikswitch': { + 'sensors': { + 'name': 's1', + 'id': '@a00001', + 'channel': 1, + 'type': 'imod', + } + } + } + await async_setup_component(hass, QWIKSWITCH, config) + await hass.async_block_till_done() + + state_obj = hass.states.get('binary_sensor.s1') + assert state_obj.state == 'off' + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + LISTEN.append('{"id":"@a00001","cmd":"","data":"4e0e1601","rssi":"61%"}') + LISTEN.append('') # Will cause a sleep + await hass.async_block_till_done() + state_obj = hass.states.get('binary_sensor.s1') + assert state_obj.state == 'on' + + LISTEN.append('{"id":"@a00001","cmd":"","data":"4e0e1701","rssi":"61%"}') + hass.data[QWIKSWITCH]._sleep_task.cancel() + await LISTEN.wait_till_empty(hass) + state_obj = hass.states.get('binary_sensor.s1') + assert state_obj.state == 'off' + + +async def test_sensor_device(hass, aioclient_mock): + """Test a sensor device.""" + config = { + 'qwikswitch': { + 'sensors': { + 'name': 'ss1', + 'id': '@a00001', + 'channel': 1, + 'type': 'qwikcord', + } + } + } + await async_setup_component(hass, QWIKSWITCH, config) + await hass.async_block_till_done() + + state_obj = hass.states.get('sensor.ss1') + assert state_obj.state == 'None' + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + LISTEN.append( + '{"id":"@a00001","name":"ss1","type":"rel",' + '"val":"4733800001a00000"}') + LISTEN.append('') # Will cause a sleep + await LISTEN.wait_till_empty(hass) # await hass.async_block_till_done() + + state_obj = hass.states.get('sensor.ss1') + assert state_obj.state == 'None' diff --git a/tests/components/test_ring.py b/tests/components/test_ring.py index 819f447f2f5..3837ec13061 100644 --- a/tests/components/test_ring.py +++ b/tests/components/test_ring.py @@ -1,4 +1,5 @@ """The tests for the Ring component.""" +from copy import deepcopy import os import unittest import requests_mock @@ -51,7 +52,7 @@ class TestRing(unittest.TestCase): """Test the setup when no login is configured.""" mock.post('https://api.ring.com/clients_api/session', text=load_fixture('ring_session.json')) - conf = self.config.copy() + conf = deepcopy(VALID_CONFIG) del conf['ring']['username'] assert not setup.setup_component(self.hass, ring.DOMAIN, conf) @@ -60,6 +61,6 @@ class TestRing(unittest.TestCase): """Test the setup when no password is configured.""" mock.post('https://api.ring.com/clients_api/session', text=load_fixture('ring_session.json')) - conf = self.config.copy() + conf = deepcopy(VALID_CONFIG) del conf['ring']['password'] assert not setup.setup_component(self.hass, ring.DOMAIN, conf) diff --git a/tests/components/test_rss_feed_template.py b/tests/components/test_rss_feed_template.py index 8b16b5519e9..36f68e57c9f 100644 --- a/tests/components/test_rss_feed_template.py +++ b/tests/components/test_rss_feed_template.py @@ -8,7 +8,7 @@ from homeassistant.setup import async_setup_component @pytest.fixture -def mock_http_client(loop, hass, test_client): +def mock_http_client(loop, hass, aiohttp_client): """Setup test fixture.""" config = { 'rss_feed_template': { @@ -21,7 +21,7 @@ def mock_http_client(loop, hass, test_client): loop.run_until_complete(async_setup_component(hass, 'rss_feed_template', config)) - return loop.run_until_complete(test_client(hass.http.app)) + return loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine diff --git a/tests/components/test_shell_command.py b/tests/components/test_shell_command.py index 6f993732c38..a1acffd62e5 100644 --- a/tests/components/test_shell_command.py +++ b/tests/components/test_shell_command.py @@ -19,8 +19,7 @@ def mock_process_creator(error: bool = False) -> asyncio.coroutine: def communicate() -> Tuple[bytes, bytes]: """Mock a coroutine that runs a process when yielded. - Returns: - a tuple of (stdout, stderr). + Returns a tuple of (stdout, stderr). """ return b"I am stdout", b"I am stderr" @@ -149,3 +148,41 @@ class TestShellCommand(unittest.TestCase): self.assertEqual(1, mock_call.call_count) self.assertEqual(1, mock_error.call_count) self.assertFalse(os.path.isfile(path)) + + @patch('homeassistant.components.shell_command._LOGGER.debug') + def test_stdout_captured(self, mock_output): + """Test subprocess that has stdout.""" + test_phrase = "I have output" + self.assertTrue( + setup_component(self.hass, shell_command.DOMAIN, { + shell_command.DOMAIN: { + 'test_service': "echo {}".format(test_phrase) + } + })) + + self.hass.services.call('shell_command', 'test_service', + blocking=True) + + self.hass.block_till_done() + self.assertEqual(1, mock_output.call_count) + self.assertEqual(test_phrase.encode() + b'\n', + mock_output.call_args_list[0][0][-1]) + + @patch('homeassistant.components.shell_command._LOGGER.debug') + def test_stderr_captured(self, mock_output): + """Test subprocess that has stderr.""" + test_phrase = "I have error" + self.assertTrue( + setup_component(self.hass, shell_command.DOMAIN, { + shell_command.DOMAIN: { + 'test_service': ">&2 echo {}".format(test_phrase) + } + })) + + self.hass.services.call('shell_command', 'test_service', + blocking=True) + + self.hass.block_till_done() + self.assertEqual(1, mock_output.call_count) + self.assertEqual(test_phrase.encode() + b'\n', + mock_output.call_args_list[0][0][-1]) diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index 4203f7587ae..3131ae092a3 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -54,7 +54,7 @@ def test_recent_items_intent(hass): @asyncio.coroutine -def test_api_get_all(hass, test_client): +def test_api_get_all(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -65,7 +65,7 @@ def test_api_get_all(hass, test_client): hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} ) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.get('/api/shopping_list') assert resp.status == 200 @@ -78,7 +78,7 @@ def test_api_get_all(hass, test_client): @asyncio.coroutine -def test_api_update(hass, test_client): +def test_api_update(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -92,7 +92,7 @@ def test_api_update(hass, test_client): beer_id = hass.data['shopping_list'].items[0]['id'] wine_id = hass.data['shopping_list'].items[1]['id'] - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.post( '/api/shopping_list/item/{}'.format(beer_id), json={ 'name': 'soda' @@ -133,7 +133,7 @@ def test_api_update(hass, test_client): @asyncio.coroutine -def test_api_update_fails(hass, test_client): +def test_api_update_fails(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -141,7 +141,7 @@ def test_api_update_fails(hass, test_client): hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} ) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.post( '/api/shopping_list/non_existing', json={ 'name': 'soda' @@ -159,7 +159,7 @@ def test_api_update_fails(hass, test_client): @asyncio.coroutine -def test_api_clear_completed(hass, test_client): +def test_api_clear_completed(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -173,7 +173,7 @@ def test_api_clear_completed(hass, test_client): beer_id = hass.data['shopping_list'].items[0]['id'] wine_id = hass.data['shopping_list'].items[1]['id'] - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) # Mark beer as completed resp = yield from client.post( @@ -196,11 +196,11 @@ def test_api_clear_completed(hass, test_client): @asyncio.coroutine -def test_api_create(hass, test_client): +def test_api_create(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.post('/api/shopping_list/item', json={ 'name': 'soda' }) @@ -217,11 +217,11 @@ def test_api_create(hass, test_client): @asyncio.coroutine -def test_api_create_fail(hass, test_client): +def test_api_create_fail(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) - client = yield from test_client(hass.http.app) + client = yield from aiohttp_client(hass.http.app) resp = yield from client.post('/api/shopping_list/item', json={ 'name': 1234 }) diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index f37beef7960..d9238336768 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -1,20 +1,92 @@ """Test the Snips component.""" -import asyncio import json import logging -from homeassistant.core import callback from homeassistant.bootstrap import async_setup_component +from homeassistant.components.mqtt import MQTT_PUBLISH_SCHEMA +import homeassistant.components.snips as snips from tests.common import (async_fire_mqtt_message, async_mock_intent, async_mock_service) -from homeassistant.components.snips import (SERVICE_SCHEMA_SAY, - SERVICE_SCHEMA_SAY_ACTION) -@asyncio.coroutine -def test_snips_intent(hass, mqtt_mock): +async def test_snips_config(hass, mqtt_mock): + """Test Snips Config.""" + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": True, + "probability_threshold": .5, + "site_ids": ["default", "remote"] + }, + }) + assert result + + +async def test_snips_bad_config(hass, mqtt_mock): + """Test Snips bad config.""" + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": "on", + "probability": "none", + "site_ids": "default" + }, + }) + assert not result + + +async def test_snips_config_feedback_on(hass, mqtt_mock): + """Test Snips Config.""" + calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA) + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": True + }, + }) + assert result + await hass.async_block_till_done() + + assert len(calls) == 2 + topic = calls[0].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOn' + topic = calls[1].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOn' + assert calls[1].data['qos'] == 1 + assert calls[1].data['retain'] + + +async def test_snips_config_feedback_off(hass, mqtt_mock): + """Test Snips Config.""" + calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA) + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": False + }, + }) + assert result + await hass.async_block_till_done() + + assert len(calls) == 2 + topic = calls[0].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOn' + topic = calls[1].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOff' + assert calls[1].data['qos'] == 0 + assert not calls[1].data['retain'] + + +async def test_snips_config_no_feedback(hass, mqtt_mock): + """Test Snips Config.""" + calls = async_mock_service(hass, 'snips', 'say') + result = await async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_snips_intent(hass, mqtt_mock): """Test intent via Snips.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -41,19 +113,20 @@ def test_snips_intent(hass, mqtt_mock): async_fire_mqtt_message(hass, 'hermes/intent/Lights', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] assert intent.platform == 'snips' assert intent.intent_type == 'Lights' - assert intent.slots == {'light_color': {'value': 'green'}} + assert intent.slots == {'light_color': {'value': 'green'}, + 'probability': {'value': 1}, + 'site_id': {'value': None}} assert intent.text_input == 'turn the lights green' -@asyncio.coroutine -def test_snips_intent_with_duration(hass, mqtt_mock): +async def test_snips_intent_with_duration(hass, mqtt_mock): """Test intent with Snips duration.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -61,7 +134,8 @@ def test_snips_intent_with_duration(hass, mqtt_mock): { "input": "set a timer of five minutes", "intent": { - "intentName": "SetTimer" + "intentName": "SetTimer", + "probability": 1 }, "slots": [ { @@ -92,30 +166,24 @@ def test_snips_intent_with_duration(hass, mqtt_mock): async_fire_mqtt_message(hass, 'hermes/intent/SetTimer', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] assert intent.platform == 'snips' assert intent.intent_type == 'SetTimer' - assert intent.slots == {'timer_duration': {'value': 300}} + assert intent.slots == {'probability': {'value': 1}, + 'site_id': {'value': None}, + 'timer_duration': {'value': 300}} -@asyncio.coroutine -def test_intent_speech_response(hass, mqtt_mock): +async def test_intent_speech_response(hass, mqtt_mock): """Test intent speech response via Snips.""" - event = 'call_service' - events = [] - - @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) - - result = yield from async_setup_component(hass, "snips", { + calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA) + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result - result = yield from async_setup_component(hass, "intent_script", { + result = await async_setup_component(hass, "intent_script", { "intent_script": { "spokenIntent": { "speech": { @@ -131,31 +199,28 @@ def test_intent_speech_response(hass, mqtt_mock): "input": "speak to me", "sessionId": "abcdef0123456789", "intent": { - "intentName": "spokenIntent" + "intentName": "spokenIntent", + "probability": 1 }, "slots": [] } """ - hass.bus.async_listen(event, record_event) async_fire_mqtt_message(hass, 'hermes/intent/spokenIntent', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() - assert len(events) == 1 - assert events[0].data['domain'] == 'mqtt' - assert events[0].data['service'] == 'publish' - payload = json.loads(events[0].data['service_data']['payload']) - topic = events[0].data['service_data']['topic'] + assert len(calls) == 1 + payload = json.loads(calls[0].data['payload']) + topic = calls[0].data['topic'] assert payload['sessionId'] == 'abcdef0123456789' assert payload['text'] == 'I am speaking to you' assert topic == 'hermes/dialogueManager/endSession' -@asyncio.coroutine -def test_unknown_intent(hass, mqtt_mock, caplog): +async def test_unknown_intent(hass, mqtt_mock, caplog): """Test unknown intent.""" caplog.set_level(logging.WARNING) - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -164,21 +229,21 @@ def test_unknown_intent(hass, mqtt_mock, caplog): "input": "I don't know what I am supposed to do", "sessionId": "abcdef1234567890", "intent": { - "intentName": "unknownIntent" + "intentName": "unknownIntent", + "probability": 1 }, "slots": [] } """ async_fire_mqtt_message(hass, 'hermes/intent/unknownIntent', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert 'Received unknown intent unknownIntent' in caplog.text -@asyncio.coroutine -def test_snips_intent_user(hass, mqtt_mock): +async def test_snips_intent_user(hass, mqtt_mock): """Test intentName format user_XXX__intentName.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -186,7 +251,8 @@ def test_snips_intent_user(hass, mqtt_mock): { "input": "what to do", "intent": { - "intentName": "user_ABCDEF123__Lights" + "intentName": "user_ABCDEF123__Lights", + "probability": 1 }, "slots": [] } @@ -194,7 +260,7 @@ def test_snips_intent_user(hass, mqtt_mock): intents = async_mock_intent(hass, 'Lights') async_fire_mqtt_message(hass, 'hermes/intent/user_ABCDEF123__Lights', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] @@ -202,10 +268,9 @@ def test_snips_intent_user(hass, mqtt_mock): assert intent.intent_type == 'Lights' -@asyncio.coroutine -def test_snips_intent_username(hass, mqtt_mock): +async def test_snips_intent_username(hass, mqtt_mock): """Test intentName format username:intentName.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -213,7 +278,8 @@ def test_snips_intent_username(hass, mqtt_mock): { "input": "what to do", "intent": { - "intentName": "username:Lights" + "intentName": "username:Lights", + "probability": 1 }, "slots": [] } @@ -221,7 +287,7 @@ def test_snips_intent_username(hass, mqtt_mock): intents = async_mock_intent(hass, 'Lights') async_fire_mqtt_message(hass, 'hermes/intent/username:Lights', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] @@ -229,15 +295,81 @@ def test_snips_intent_username(hass, mqtt_mock): assert intent.intent_type == 'Lights' -@asyncio.coroutine -def test_snips_say(hass, caplog): - """Test snips say with invalid config.""" - calls = async_mock_service(hass, 'snips', 'say', - SERVICE_SCHEMA_SAY) +async def test_snips_low_probability(hass, mqtt_mock, caplog): + """Test intent via Snips.""" + caplog.set_level(logging.WARNING) + result = await async_setup_component(hass, "snips", { + "snips": { + "probability_threshold": 0.5 + }, + }) + assert result + payload = """ + { + "input": "I am not sure what to say", + "intent": { + "intentName": "LightsMaybe", + "probability": 0.49 + }, + "slots": [] + } + """ + async_mock_intent(hass, 'LightsMaybe') + async_fire_mqtt_message(hass, 'hermes/intent/LightsMaybe', + payload) + await hass.async_block_till_done() + assert 'Intent below probaility threshold 0.49 < 0.5' in caplog.text + + +async def test_intent_special_slots(hass, mqtt_mock): + """Test intent special slot values via Snips.""" + calls = async_mock_service(hass, 'light', 'turn_on') + result = await async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + result = await async_setup_component(hass, "intent_script", { + "intent_script": { + "Lights": { + "action": { + "service": "light.turn_on", + "data_template": { + "probability": "{{ probability }}", + "site_id": "{{ site_id }}" + } + } + } + } + }) + assert result + payload = """ + { + "input": "turn the light on", + "intent": { + "intentName": "Lights", + "probability": 0.85 + }, + "siteId": "default", + "slots": [] + } + """ + async_fire_mqtt_message(hass, 'hermes/intent/Lights', payload) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'light' + assert calls[0].service == 'turn_on' + assert calls[0].data['probability'] == '0.85' + assert calls[0].data['site_id'] == 'default' + + +async def test_snips_say(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'say', snips.SERVICE_SCHEMA_SAY) data = {'text': 'Hello'} - yield from hass.services.async_call('snips', 'say', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say', data) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].domain == 'snips' @@ -245,15 +377,14 @@ def test_snips_say(hass, caplog): assert calls[0].data['text'] == 'Hello' -@asyncio.coroutine -def test_snips_say_action(hass, caplog): +async def test_snips_say_action(hass, caplog): """Test snips say_action with invalid config.""" calls = async_mock_service(hass, 'snips', 'say_action', - SERVICE_SCHEMA_SAY_ACTION) + snips.SERVICE_SCHEMA_SAY_ACTION) data = {'text': 'Hello', 'intent_filter': ['myIntent']} - yield from hass.services.async_call('snips', 'say_action', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say_action', data) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].domain == 'snips' @@ -262,31 +393,71 @@ def test_snips_say_action(hass, caplog): assert calls[0].data['intent_filter'] == ['myIntent'] -@asyncio.coroutine -def test_snips_say_invalid_config(hass, caplog): +async def test_snips_say_invalid_config(hass, caplog): """Test snips say with invalid config.""" calls = async_mock_service(hass, 'snips', 'say', - SERVICE_SCHEMA_SAY) + snips.SERVICE_SCHEMA_SAY) data = {'text': 'Hello', 'badKey': 'boo'} - yield from hass.services.async_call('snips', 'say', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say', data) + await hass.async_block_till_done() assert len(calls) == 0 assert 'ERROR' in caplog.text assert 'Invalid service data' in caplog.text -@asyncio.coroutine -def test_snips_say_action_invalid_config(hass, caplog): +async def test_snips_say_action_invalid(hass, caplog): """Test snips say_action with invalid config.""" calls = async_mock_service(hass, 'snips', 'say_action', - SERVICE_SCHEMA_SAY_ACTION) + snips.SERVICE_SCHEMA_SAY_ACTION) data = {'text': 'Hello', 'can_be_enqueued': 'notabool'} - yield from hass.services.async_call('snips', 'say_action', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say_action', data) + await hass.async_block_till_done() assert len(calls) == 0 assert 'ERROR' in caplog.text assert 'Invalid service data' in caplog.text + + +async def test_snips_feedback_on(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'feedback_on', + snips.SERVICE_SCHEMA_FEEDBACK) + + data = {'site_id': 'remote'} + await hass.services.async_call('snips', 'feedback_on', data) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'snips' + assert calls[0].service == 'feedback_on' + assert calls[0].data['site_id'] == 'remote' + + +async def test_snips_feedback_off(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'feedback_off', + snips.SERVICE_SCHEMA_FEEDBACK) + + data = {'site_id': 'remote'} + await hass.services.async_call('snips', 'feedback_off', data) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'snips' + assert calls[0].service == 'feedback_off' + assert calls[0].data['site_id'] == 'remote' + + +async def test_snips_feedback_config(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'feedback_on', + snips.SERVICE_SCHEMA_FEEDBACK) + + data = {'site_id': 'remote', 'test': 'test'} + await hass.services.async_call('snips', 'feedback_on', data) + await hass.async_block_till_done() + + assert len(calls) == 0 diff --git a/tests/components/test_spaceapi.py b/tests/components/test_spaceapi.py new file mode 100644 index 00000000000..e7e7d158a31 --- /dev/null +++ b/tests/components/test_spaceapi.py @@ -0,0 +1,113 @@ +"""The tests for the Home Assistant SpaceAPI component.""" +# pylint: disable=protected-access +from unittest.mock import patch + +import pytest +from tests.common import mock_coro + +from homeassistant.components.spaceapi import ( + DOMAIN, SPACEAPI_VERSION, URL_API_SPACEAPI) +from homeassistant.setup import async_setup_component + +CONFIG = { + DOMAIN: { + 'space': 'Home', + 'logo': 'https://home-assistant.io/logo.png', + 'url': 'https://home-assistant.io', + 'location': {'address': 'In your Home'}, + 'contact': {'email': 'hello@home-assistant.io'}, + 'issue_report_channels': ['email'], + 'state': { + 'entity_id': 'test.test_door', + 'icon_open': 'https://home-assistant.io/open.png', + 'icon_closed': 'https://home-assistant.io/close.png', + }, + 'sensors': { + 'temperature': ['test.temp1', 'test.temp2'], + 'humidity': ['test.hum1'], + } + } +} + +SENSOR_OUTPUT = { + 'temperature': [ + { + 'location': 'Home', + 'name': 'temp1', + 'unit': '°C', + 'value': '25' + }, + { + 'location': 'Home', + 'name': 'temp2', + 'unit': '°C', + 'value': '23' + }, + ], + 'humidity': [ + { + 'location': 'Home', + 'name': 'hum1', + 'unit': '%', + 'value': '88' + }, + ] +} + + +@pytest.fixture +def mock_client(hass, aiohttp_client): + """Start the Home Assistant HTTP component.""" + with patch('homeassistant.components.spaceapi', + return_value=mock_coro(True)): + hass.loop.run_until_complete( + async_setup_component(hass, 'spaceapi', CONFIG)) + + hass.states.async_set('test.temp1', 25, + attributes={'unit_of_measurement': '°C'}) + hass.states.async_set('test.temp2', 23, + attributes={'unit_of_measurement': '°C'}) + hass.states.async_set('test.hum1', 88, + attributes={'unit_of_measurement': '%'}) + + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + + +async def test_spaceapi_get(hass, mock_client): + """Test response after start-up Home Assistant.""" + resp = await mock_client.get(URL_API_SPACEAPI) + assert resp.status == 200 + + data = await resp.json() + + assert data['api'] == SPACEAPI_VERSION + assert data['space'] == 'Home' + assert data['contact']['email'] == 'hello@home-assistant.io' + assert data['location']['address'] == 'In your Home' + assert data['location']['latitude'] == 32.87336 + assert data['location']['longitude'] == -117.22743 + assert data['state']['open'] == 'null' + assert data['state']['icon']['open'] == \ + 'https://home-assistant.io/open.png' + assert data['state']['icon']['close'] == \ + 'https://home-assistant.io/close.png' + + +async def test_spaceapi_state_get(hass, mock_client): + """Test response if the state entity was set.""" + hass.states.async_set('test.test_door', True) + + resp = await mock_client.get(URL_API_SPACEAPI) + assert resp.status == 200 + + data = await resp.json() + assert data['state']['open'] == bool(1) + + +async def test_spaceapi_sensors_get(hass, mock_client): + """Test the response for the sensors.""" + resp = await mock_client.get(URL_API_SPACEAPI) + assert resp.status == 200 + + data = await resp.json() + assert data['sensors'] == SENSOR_OUTPUT diff --git a/tests/components/test_system_log.py b/tests/components/test_system_log.py index d119c60dba2..59e99e5c1b5 100644 --- a/tests/components/test_system_log.py +++ b/tests/components/test_system_log.py @@ -1,33 +1,26 @@ """Test system log component.""" -import asyncio import logging from unittest.mock import MagicMock, patch -import pytest - from homeassistant.core import callback from homeassistant.bootstrap import async_setup_component from homeassistant.components import system_log _LOGGER = logging.getLogger('test_logger') +BASIC_CONFIG = { + 'system_log': { + 'max_entries': 2, + } +} -@pytest.fixture(autouse=True) -@asyncio.coroutine -def setup_test_case(hass, test_client): - """Setup system_log component before test case.""" - config = {'system_log': {'max_entries': 2}} - yield from async_setup_component(hass, system_log.DOMAIN, config) - - -@asyncio.coroutine -def get_error_log(hass, test_client, expected_count): +async def get_error_log(hass, aiohttp_client, expected_count): """Fetch all entries from system_log via the API.""" - client = yield from test_client(hass.http.app) - resp = yield from client.get('/api/error/all') + client = await aiohttp_client(hass.http.app) + resp = await client.get('/api/error/all') assert resp.status == 200 - data = yield from resp.json() + data = await resp.json() assert len(data) == expected_count return data @@ -52,43 +45,43 @@ def get_frame(name): return (name, None, None, None) -@asyncio.coroutine -def test_normal_logs(hass, test_client): +async def test_normal_logs(hass, aiohttp_client): """Test that debug and info are not logged.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.debug('debug') _LOGGER.info('info') # Assert done by get_error_log - yield from get_error_log(hass, test_client, 0) + await get_error_log(hass, aiohttp_client, 0) -@asyncio.coroutine -def test_exception(hass, test_client): +async def test_exception(hass, aiohttp_client): """Test that exceptions are logged and retrieved correctly.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _generate_and_log_exception('exception message', 'log message') - log = (yield from get_error_log(hass, test_client, 1))[0] + log = (await get_error_log(hass, aiohttp_client, 1))[0] assert_log(log, 'exception message', 'log message', 'ERROR') -@asyncio.coroutine -def test_warning(hass, test_client): +async def test_warning(hass, aiohttp_client): """Test that warning are logged and retrieved correctly.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.warning('warning message') - log = (yield from get_error_log(hass, test_client, 1))[0] + log = (await get_error_log(hass, aiohttp_client, 1))[0] assert_log(log, '', 'warning message', 'WARNING') -@asyncio.coroutine -def test_error(hass, test_client): +async def test_error(hass, aiohttp_client): """Test that errors are logged and retrieved correctly.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error('error message') - log = (yield from get_error_log(hass, test_client, 1))[0] + log = (await get_error_log(hass, aiohttp_client, 1))[0] assert_log(log, '', 'error message', 'ERROR') -@asyncio.coroutine -def test_error_posted_as_event(hass, test_client): - """Test that error are posted as events.""" +async def test_config_not_fire_event(hass): + """Test that errors are not posted as events with default config.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) events = [] @callback @@ -99,77 +92,100 @@ def test_error_posted_as_event(hass, test_client): hass.bus.async_listen(system_log.EVENT_SYSTEM_LOG, event_listener) _LOGGER.error('error message') - yield from hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(events) == 0 + + +async def test_error_posted_as_event(hass): + """Test that error are posted as events.""" + await async_setup_component(hass, system_log.DOMAIN, { + 'system_log': { + 'max_entries': 2, + 'fire_event': True, + } + }) + events = [] + + @callback + def event_listener(event): + """Listen to events of type system_log_event.""" + events.append(event) + + hass.bus.async_listen(system_log.EVENT_SYSTEM_LOG, event_listener) + + _LOGGER.error('error message') + await hass.async_block_till_done() assert len(events) == 1 assert_log(events[0].data, '', 'error message', 'ERROR') -@asyncio.coroutine -def test_critical(hass, test_client): +async def test_critical(hass, aiohttp_client): """Test that critical are logged and retrieved correctly.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.critical('critical message') - log = (yield from get_error_log(hass, test_client, 1))[0] + log = (await get_error_log(hass, aiohttp_client, 1))[0] assert_log(log, '', 'critical message', 'CRITICAL') -@asyncio.coroutine -def test_remove_older_logs(hass, test_client): +async def test_remove_older_logs(hass, aiohttp_client): """Test that older logs are rotated out.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error('error message 1') _LOGGER.error('error message 2') _LOGGER.error('error message 3') - log = yield from get_error_log(hass, test_client, 2) + log = await get_error_log(hass, aiohttp_client, 2) assert_log(log[0], '', 'error message 3', 'ERROR') assert_log(log[1], '', 'error message 2', 'ERROR') -@asyncio.coroutine -def test_clear_logs(hass, test_client): +async def test_clear_logs(hass, aiohttp_client): """Test that the log can be cleared via a service call.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error('error message') hass.async_add_job( hass.services.async_call( system_log.DOMAIN, system_log.SERVICE_CLEAR, {})) - yield from hass.async_block_till_done() + await hass.async_block_till_done() # Assert done by get_error_log - yield from get_error_log(hass, test_client, 0) + await get_error_log(hass, aiohttp_client, 0) -@asyncio.coroutine -def test_write_log(hass): +async def test_write_log(hass): """Test that error propagates to logger.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) logger = MagicMock() with patch('logging.getLogger', return_value=logger) as mock_logging: hass.async_add_job( hass.services.async_call( system_log.DOMAIN, system_log.SERVICE_WRITE, {'message': 'test_message'})) - yield from hass.async_block_till_done() + await hass.async_block_till_done() mock_logging.assert_called_once_with( 'homeassistant.components.system_log.external') assert logger.method_calls[0] == ('error', ('test_message',)) -@asyncio.coroutine -def test_write_choose_logger(hass): +async def test_write_choose_logger(hass): """Test that correct logger is chosen.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch('logging.getLogger') as mock_logging: hass.async_add_job( hass.services.async_call( system_log.DOMAIN, system_log.SERVICE_WRITE, {'message': 'test_message', 'logger': 'myLogger'})) - yield from hass.async_block_till_done() + await hass.async_block_till_done() mock_logging.assert_called_once_with( 'myLogger') -@asyncio.coroutine -def test_write_choose_level(hass): +async def test_write_choose_level(hass): """Test that correct logger is chosen.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) logger = MagicMock() with patch('logging.getLogger', return_value=logger): hass.async_add_job( @@ -177,17 +193,17 @@ def test_write_choose_level(hass): system_log.DOMAIN, system_log.SERVICE_WRITE, {'message': 'test_message', 'level': 'debug'})) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert logger.method_calls[0] == ('debug', ('test_message',)) -@asyncio.coroutine -def test_unknown_path(hass, test_client): +async def test_unknown_path(hass, aiohttp_client): """Test error logged from unknown path.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.findCaller = MagicMock( return_value=('unknown_path', 0, None, None)) _LOGGER.error('error message') - log = (yield from get_error_log(hass, test_client, 1))[0] + log = (await get_error_log(hass, aiohttp_client, 1))[0] assert log['source'] == 'unknown_path' @@ -206,31 +222,31 @@ def log_error_from_test_path(path): _LOGGER.error('error message') -@asyncio.coroutine -def test_homeassistant_path(hass, test_client): +async def test_homeassistant_path(hass, aiohttp_client): """Test error logged from homeassistant path.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch('homeassistant.components.system_log.HOMEASSISTANT_PATH', new=['venv_path/homeassistant']): log_error_from_test_path( 'venv_path/homeassistant/component/component.py') - log = (yield from get_error_log(hass, test_client, 1))[0] + log = (await get_error_log(hass, aiohttp_client, 1))[0] assert log['source'] == 'component/component.py' -@asyncio.coroutine -def test_config_path(hass, test_client): +async def test_config_path(hass, aiohttp_client): """Test error logged from config path.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch.object(hass.config, 'config_dir', new='config'): log_error_from_test_path('config/custom_component/test.py') - log = (yield from get_error_log(hass, test_client, 1))[0] + log = (await get_error_log(hass, aiohttp_client, 1))[0] assert log['source'] == 'custom_component/test.py' -@asyncio.coroutine -def test_netdisco_path(hass, test_client): +async def test_netdisco_path(hass, aiohttp_client): """Test error logged from netdisco path.""" + await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch.dict('sys.modules', netdisco=MagicMock(__path__=['venv_path/netdisco'])): log_error_from_test_path('venv_path/netdisco/disco_component.py') - log = (yield from get_error_log(hass, test_client, 1))[0] + log = (await get_error_log(hass, aiohttp_client, 1))[0] assert log['source'] == 'disco_component.py' diff --git a/tests/components/test_upnp.py b/tests/components/test_upnp.py index e2096d28e58..4956b8a6278 100644 --- a/tests/components/test_upnp.py +++ b/tests/components/test_upnp.py @@ -1,5 +1,4 @@ """Test the UPNP component.""" -import asyncio from collections import OrderedDict from unittest.mock import patch, MagicMock @@ -7,15 +6,64 @@ import pytest from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.setup import async_setup_component +from homeassistant.components.upnp import IP_SERVICE, DATA_UPNP + + +class MockService(MagicMock): + """Mock upnp IP service.""" + + async def add_port_mapping(self, *args, **kwargs): + """Original function.""" + self.mock_add_port_mapping(*args, **kwargs) + + async def delete_port_mapping(self, *args, **kwargs): + """Original function.""" + self.mock_delete_port_mapping(*args, **kwargs) + + +class MockDevice(MagicMock): + """Mock upnp device.""" + + def find_first_service(self, *args, **kwargs): + """Original function.""" + self._service = MockService() + return self._service + + def peep_first_service(self): + """Access Mock first service.""" + return self._service + + +class MockResp(MagicMock): + """Mock upnp msearch response.""" + + async def get_device(self, *args, **kwargs): + """Original function.""" + device = MockDevice() + service = {'serviceType': IP_SERVICE} + device.services = [service] + return device @pytest.fixture -def mock_miniupnpc(): - """Mock miniupnpc.""" - mock = MagicMock() +def mock_msearch_first(*args, **kwargs): + """Wrapper to async mock function.""" + async def async_mock_msearch_first(*args, **kwargs): + """Mock msearch_first.""" + return MockResp(*args, **kwargs) - with patch.dict('sys.modules', {'miniupnpc': mock}): - yield mock.UPnP() + with patch('pyupnp_async.msearch_first', new=async_mock_msearch_first): + yield + + +@pytest.fixture +def mock_async_exception(*args, **kwargs): + """Wrapper to async mock function with exception.""" + async def async_mock_exception(*args, **kwargs): + return Exception + + with patch('pyupnp_async.msearch_first', new=async_mock_exception): + yield @pytest.fixture @@ -26,75 +74,66 @@ def mock_local_ip(): yield -@pytest.fixture(autouse=True) -def mock_discovery(): - """Mock discovery of upnp sensor.""" - with patch('homeassistant.components.upnp.discovery'): - yield - - -@asyncio.coroutine -def test_setup_fail_if_no_ip(hass): +async def test_setup_fail_if_no_ip(hass): """Test setup fails if we can't find a local IP.""" with patch('homeassistant.components.upnp.get_local_ip', return_value='127.0.0.1'): - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': {} }) assert not result -@asyncio.coroutine -def test_setup_fail_if_cannot_select_igd(hass, mock_local_ip, mock_miniupnpc): +async def test_setup_fail_if_cannot_select_igd(hass, + mock_local_ip, + mock_async_exception): """Test setup fails if we can't find an UPnP IGD.""" - mock_miniupnpc.selectigd.side_effect = Exception - - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': {} }) assert not result -@asyncio.coroutine -def test_setup_succeeds_if_specify_ip(hass, mock_miniupnpc): +async def test_setup_succeeds_if_specify_ip(hass, mock_msearch_first): """Test setup succeeds if we specify IP and can't find a local IP.""" with patch('homeassistant.components.upnp.get_local_ip', return_value='127.0.0.1'): - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': { 'local_ip': '192.168.0.10' } }) assert result + mock_service = hass.data[DATA_UPNP].peep_first_service() + assert len(mock_service.mock_add_port_mapping.mock_calls) == 1 + mock_service.mock_add_port_mapping.assert_called_once_with( + 8123, 8123, '192.168.0.10', 'TCP', desc='Home Assistant') -@asyncio.coroutine -def test_no_config_maps_hass_local_to_remote_port(hass, mock_miniupnpc): +async def test_no_config_maps_hass_local_to_remote_port(hass, + mock_local_ip, + mock_msearch_first): """Test by default we map local to remote port.""" - result = yield from async_setup_component(hass, 'upnp', { - 'upnp': { - 'local_ip': '192.168.0.10' - } + result = await async_setup_component(hass, 'upnp', { + 'upnp': {} }) assert result - assert len(mock_miniupnpc.addportmapping.mock_calls) == 1 - external, _, host, internal, _, _ = \ - mock_miniupnpc.addportmapping.mock_calls[0][1] - assert host == '192.168.0.10' - assert external == 8123 - assert internal == 8123 + mock_service = hass.data[DATA_UPNP].peep_first_service() + assert len(mock_service.mock_add_port_mapping.mock_calls) == 1 + mock_service.mock_add_port_mapping.assert_called_once_with( + 8123, 8123, '192.168.0.10', 'TCP', desc='Home Assistant') -@asyncio.coroutine -def test_map_hass_to_remote_port(hass, mock_miniupnpc): +async def test_map_hass_to_remote_port(hass, + mock_local_ip, + mock_msearch_first): """Test mapping hass to remote port.""" - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': { - 'local_ip': '192.168.0.10', 'ports': { 'hass': 1000 } @@ -102,41 +141,38 @@ def test_map_hass_to_remote_port(hass, mock_miniupnpc): }) assert result - assert len(mock_miniupnpc.addportmapping.mock_calls) == 1 - external, _, host, internal, _, _ = \ - mock_miniupnpc.addportmapping.mock_calls[0][1] - assert external == 1000 - assert internal == 8123 + mock_service = hass.data[DATA_UPNP].peep_first_service() + assert len(mock_service.mock_add_port_mapping.mock_calls) == 1 + mock_service.mock_add_port_mapping.assert_called_once_with( + 8123, 1000, '192.168.0.10', 'TCP', desc='Home Assistant') -@asyncio.coroutine -def test_map_internal_to_remote_ports(hass, mock_miniupnpc): +async def test_map_internal_to_remote_ports(hass, + mock_local_ip, + mock_msearch_first): """Test mapping local to remote ports.""" ports = OrderedDict() ports['hass'] = 1000 ports[1883] = 3883 - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': { - 'local_ip': '192.168.0.10', 'ports': ports } }) assert result - assert len(mock_miniupnpc.addportmapping.mock_calls) == 2 - external, _, host, internal, _, _ = \ - mock_miniupnpc.addportmapping.mock_calls[0][1] - assert external == 1000 - assert internal == 8123 + mock_service = hass.data[DATA_UPNP].peep_first_service() + assert len(mock_service.mock_add_port_mapping.mock_calls) == 2 - external, _, host, internal, _, _ = \ - mock_miniupnpc.addportmapping.mock_calls[1][1] - assert external == 3883 - assert internal == 1883 + mock_service.mock_add_port_mapping.assert_any_call( + 8123, 1000, '192.168.0.10', 'TCP', desc='Home Assistant') + mock_service.mock_add_port_mapping.assert_any_call( + 1883, 3883, '192.168.0.10', 'TCP', desc='Home Assistant') hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - yield from hass.async_block_till_done() - assert len(mock_miniupnpc.deleteportmapping.mock_calls) == 2 - assert mock_miniupnpc.deleteportmapping.mock_calls[0][1][0] == 1000 - assert mock_miniupnpc.deleteportmapping.mock_calls[1][1][0] == 3883 + await hass.async_block_till_done() + assert len(mock_service.mock_delete_port_mapping.mock_calls) == 2 + + mock_service.mock_delete_port_mapping.assert_any_call(1000, 'TCP') + mock_service.mock_delete_port_mapping.assert_any_call(3883, 'TCP') diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index d0c129e512e..cff103142b0 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -7,7 +7,7 @@ from async_timeout import timeout import pytest from homeassistant.core import callback -from homeassistant.components import websocket_api as wapi, frontend +from homeassistant.components import websocket_api as wapi from homeassistant.setup import async_setup_component from tests.common import mock_coro @@ -16,24 +16,13 @@ API_PASSWORD = 'test1234' @pytest.fixture -def websocket_client(loop, hass, test_client): - """Websocket client fixture connected to websocket server.""" - assert loop.run_until_complete( - async_setup_component(hass, 'websocket_api')) - - client = loop.run_until_complete(test_client(hass.http.app)) - ws = loop.run_until_complete(client.ws_connect(wapi.URL)) - auth_ok = loop.run_until_complete(ws.receive_json()) - assert auth_ok['type'] == wapi.TYPE_AUTH_OK - - yield ws - - if not ws.closed: - loop.run_until_complete(ws.close()) +def websocket_client(hass, hass_ws_client): + """Create a websocket client.""" + return hass.loop.run_until_complete(hass_ws_client(hass)) @pytest.fixture -def no_auth_websocket_client(hass, loop, test_client): +def no_auth_websocket_client(hass, loop, aiohttp_client): """Websocket connection that requires authentication.""" assert loop.run_until_complete( async_setup_component(hass, 'websocket_api', { @@ -42,7 +31,7 @@ def no_auth_websocket_client(hass, loop, test_client): } })) - client = loop.run_until_complete(test_client(hass.http.app)) + client = loop.run_until_complete(aiohttp_client(hass.http.app)) ws = loop.run_until_complete(client.ws_connect(wapi.URL)) auth_ok = loop.run_until_complete(ws.receive_json()) @@ -289,31 +278,6 @@ def test_get_config(hass, websocket_client): assert msg['result'] == hass.config.as_dict() -@asyncio.coroutine -def test_get_panels(hass, websocket_client): - """Test get_panels command.""" - yield from hass.components.frontend.async_register_built_in_panel( - 'map', 'Map', 'mdi:account-location') - hass.data[frontend.DATA_JS_VERSION] = 'es5' - yield from websocket_client.send_json({ - 'id': 5, - 'type': wapi.TYPE_GET_PANELS, - }) - - msg = yield from websocket_client.receive_json() - assert msg['id'] == 5 - assert msg['type'] == wapi.TYPE_RESULT - assert msg['success'] - assert msg['result'] == {'map': { - 'component_name': 'map', - 'url_path': 'map', - 'config': None, - 'url': None, - 'icon': 'mdi:account-location', - 'title': 'Map', - }} - - @asyncio.coroutine def test_ping(websocket_client): """Test get_panels command.""" @@ -337,3 +301,61 @@ def test_pending_msg_overflow(hass, mock_low_queue, websocket_client): }) msg = yield from websocket_client.receive() assert msg.type == WSMsgType.close + + +@asyncio.coroutine +def test_unknown_command(websocket_client): + """Test get_panels command.""" + yield from websocket_client.send_json({ + 'id': 5, + 'type': 'unknown_command', + }) + + msg = yield from websocket_client.receive() + assert msg.type == WSMsgType.close + + +async def test_auth_with_token(hass, aiohttp_client, hass_access_token): + """Test authenticating with a token.""" + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'access_token': hass_access_token.token + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_OK + + +async def test_auth_with_invalid_token(hass, aiohttp_client): + """Test authenticating with a token.""" + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'access_token': 'incorrect' + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_INVALID diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 7a15ed28f97..b6bfa430fd2 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -2,6 +2,7 @@ import ctypes import os import shutil +import json from unittest.mock import patch, PropertyMock import pytest @@ -353,7 +354,7 @@ class TestTTS(object): demo_data = tts.SpeechManager.write_tags( "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3", demo_data, self.demo_provider, - "I person is on front of your door.", 'en', None) + "AI person is in front of your door.", 'en', None) assert req.status_code == 200 assert req.content == demo_data @@ -562,3 +563,46 @@ class TestTTS(object): req = requests.get(url) assert req.status_code == 200 assert req.content == demo_data + + def test_setup_component_and_web_get_url(self): + """Setup the demo platform and receive wrong file from web.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.start() + + url = ("{}/api/tts_get_url").format(self.hass.config.api.base_url) + data = {'platform': 'demo', + 'message': "I person is on front of your door."} + + req = requests.post(url, data=json.dumps(data)) + assert req.status_code == 200 + response = json.loads(req.text) + assert response.get('url') == (("{}/api/tts_proxy/265944c108cbb00b2a62" + "1be5930513e03a0bb2cd_en_-_demo.mp3") + .format(self.hass.config.api.base_url)) + + def test_setup_component_and_web_get_url_bad_config(self): + """Setup the demo platform and receive wrong file from web.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.start() + + url = ("{}/api/tts_get_url").format(self.hass.config.api.base_url) + data = {'message': "I person is on front of your door."} + + req = requests.post(url, data=data) + assert req.status_code == 400 diff --git a/tests/components/vacuum/test_dyson.py b/tests/components/vacuum/test_dyson.py index 186a2271a73..8a4e6d57b91 100644 --- a/tests/components/vacuum/test_dyson.py +++ b/tests/components/vacuum/test_dyson.py @@ -118,7 +118,6 @@ class DysonTest(unittest.TestCase): component3 = Dyson360EyeDevice(device3) self.assertEqual(component.name, "Device_Vacuum") self.assertTrue(component.is_on) - self.assertEqual(component.icon, "mdi:roomba") self.assertEqual(component.status, "Cleaning") self.assertEqual(component2.status, "Unknown") self.assertEqual(component.battery_level, 85) diff --git a/tests/components/weather/test_darksky.py b/tests/components/weather/test_darksky.py index 787aca2ca17..7faa033e0a8 100644 --- a/tests/components/weather/test_darksky.py +++ b/tests/components/weather/test_darksky.py @@ -49,6 +49,3 @@ class TestDarkSky(unittest.TestCase): state = self.hass.states.get('weather.test') self.assertEqual(state.state, 'Clear') - self.assertEqual(state.attributes['daily_forecast_summary'], - 'No precipitation throughout the week, with ' - 'temperatures falling to 66°F on Thursday.') diff --git a/tests/components/weather/test_ipma.py b/tests/components/weather/test_ipma.py new file mode 100644 index 00000000000..7df6166a2b6 --- /dev/null +++ b/tests/components/weather/test_ipma.py @@ -0,0 +1,85 @@ +"""The tests for the IPMA weather component.""" +import unittest +from unittest.mock import patch +from collections import namedtuple + +from homeassistant.components import weather +from homeassistant.components.weather import ( + ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED) +from homeassistant.util.unit_system import METRIC_SYSTEM +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant, MockDependency + + +class MockStation(): + """Mock Station from pyipma.""" + + @classmethod + async def get(cls, websession, lat, lon): + """Mock Factory.""" + return MockStation() + + async def observation(self): + """Mock Observation.""" + Observation = namedtuple('Observation', ['temperature', 'humidity', + 'windspeed', 'winddirection', + 'precipitation', 'pressure', + 'description']) + + return Observation(18, 71.0, 3.94, 'NW', 0, 1000.0, '---') + + async def forecast(self): + """Mock Forecast.""" + Forecast = namedtuple('Forecast', ['precipitaProb', 'tMin', 'tMax', + 'predWindDir', 'idWeatherType', + 'classWindSpeed', 'longitude', + 'forecastDate', 'classPrecInt', + 'latitude', 'description']) + + return [Forecast(73.0, 13.7, 18.7, 'NW', 6, 2, -8.64, + '2018-05-31', 2, 40.61, + 'Aguaceiros, com vento Moderado de Noroeste')] + + @property + def local(self): + """Mock location.""" + return "HomeTown" + + +class TestIPMA(unittest.TestCase): + """Test the IPMA weather component.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = METRIC_SYSTEM + self.lat = self.hass.config.latitude = 40.00 + self.lon = self.hass.config.longitude = -8.00 + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + @MockDependency("pyipma") + @patch("pyipma.Station", new=MockStation) + def test_setup(self, mock_pyipma): + """Test for successfully setting up the IPMA platform.""" + self.assertTrue(setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'HomeTown', + 'platform': 'ipma', + } + })) + + state = self.hass.states.get('weather.hometown') + self.assertEqual(state.state, 'rainy') + + data = state.attributes + self.assertEqual(data.get(ATTR_WEATHER_TEMPERATURE), 18.0) + self.assertEqual(data.get(ATTR_WEATHER_HUMIDITY), 71) + self.assertEqual(data.get(ATTR_WEATHER_PRESSURE), 1000.0) + self.assertEqual(data.get(ATTR_WEATHER_WIND_SPEED), 3.94) + self.assertEqual(data.get(ATTR_WEATHER_WIND_BEARING), 'NW') + self.assertEqual(state.attributes.get('friendly_name'), 'HomeTown') diff --git a/tests/components/weather/test_weather.py b/tests/components/weather/test_weather.py index 9d22b1ad0ae..a88e9979551 100644 --- a/tests/components/weather/test_weather.py +++ b/tests/components/weather/test_weather.py @@ -5,7 +5,8 @@ from homeassistant.components import weather from homeassistant.components.weather import ( ATTR_WEATHER_ATTRIBUTION, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_OZONE, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, - ATTR_WEATHER_WIND_SPEED, ATTR_FORECAST, ATTR_FORECAST_TEMP) + ATTR_WEATHER_WIND_SPEED, ATTR_FORECAST, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW) from homeassistant.util.unit_system import METRIC_SYSTEM from homeassistant.setup import setup_component @@ -45,8 +46,17 @@ class TestWeather(unittest.TestCase): assert data.get(ATTR_WEATHER_OZONE) is None assert data.get(ATTR_WEATHER_ATTRIBUTION) == \ 'Powered by Home Assistant' + assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_CONDITION) == \ + 'rainy' + assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_PRECIPITATION) == 1 assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_TEMP) == 22 + assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_TEMP_LOW) == 15 + assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_CONDITION) == \ + 'fog' + assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_PRECIPITATION) \ + == 0.2 assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_TEMP) == 21 + assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_TEMP_LOW) == 12 assert len(data.get(ATTR_FORECAST)) == 7 def test_temperature_convert(self): diff --git a/tests/components/zone/__init__.py b/tests/components/zone/__init__.py new file mode 100644 index 00000000000..2ba325fce81 --- /dev/null +++ b/tests/components/zone/__init__.py @@ -0,0 +1 @@ +"""Tests for the zone component.""" diff --git a/tests/components/zone/test_config_flow.py b/tests/components/zone/test_config_flow.py new file mode 100644 index 00000000000..d8ee6f7c5c0 --- /dev/null +++ b/tests/components/zone/test_config_flow.py @@ -0,0 +1,55 @@ +"""Tests for zone config flow.""" + +from homeassistant.components.zone import config_flow +from homeassistant.components.zone.const import CONF_PASSIVE, DOMAIN, HOME_ZONE +from homeassistant.const import ( + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS) + +from tests.common import MockConfigEntry + + +async def test_flow_works(hass): + """Test that config flow works.""" + flow = config_flow.ZoneFlowHandler() + flow.hass = hass + + result = await flow.async_step_init(user_input={ + CONF_NAME: 'Name', + CONF_LATITUDE: '1.1', + CONF_LONGITUDE: '2.2', + CONF_RADIUS: '100', + CONF_ICON: 'mdi:home', + CONF_PASSIVE: True + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Name' + assert result['data'] == { + CONF_NAME: 'Name', + CONF_LATITUDE: '1.1', + CONF_LONGITUDE: '2.2', + CONF_RADIUS: '100', + CONF_ICON: 'mdi:home', + CONF_PASSIVE: True + } + + +async def test_flow_requires_unique_name(hass): + """Test that config flow verifies that each zones name is unique.""" + MockConfigEntry(domain=DOMAIN, data={ + CONF_NAME: 'Name' + }).add_to_hass(hass) + flow = config_flow.ZoneFlowHandler() + flow.hass = hass + + result = await flow.async_step_init(user_input={CONF_NAME: 'Name'}) + assert result['errors'] == {'base': 'name_exists'} + + +async def test_flow_requires_name_different_from_home(hass): + """Test that config flow verifies that each zones name is unique.""" + flow = config_flow.ZoneFlowHandler() + flow.hass = hass + + result = await flow.async_step_init(user_input={CONF_NAME: HOME_ZONE}) + assert result['errors'] == {'base': 'name_exists'} diff --git a/tests/components/test_zone.py b/tests/components/zone/test_init.py similarity index 55% rename from tests/components/test_zone.py rename to tests/components/zone/test_init.py index 0ea84324362..1c698438f2c 100644 --- a/tests/components/test_zone.py +++ b/tests/components/zone/test_init.py @@ -1,10 +1,42 @@ """Test zone component.""" + import unittest +from unittest.mock import Mock from homeassistant import setup from homeassistant.components import zone from tests.common import get_test_home_assistant +from tests.common import MockConfigEntry + + +async def test_setup_entry_successful(hass): + """Test setup entry is successful.""" + entry = Mock() + entry.data = { + zone.CONF_NAME: 'Test Zone', + zone.CONF_LATITUDE: 1.1, + zone.CONF_LONGITUDE: -2.2, + zone.CONF_RADIUS: 250, + zone.CONF_RADIUS: True + } + hass.data[zone.DOMAIN] = {} + assert await zone.async_setup_entry(hass, entry) is True + assert 'test_zone' in hass.data[zone.DOMAIN] + + +async def test_unload_entry_successful(hass): + """Test unload entry is successful.""" + entry = Mock() + entry.data = { + zone.CONF_NAME: 'Test Zone', + zone.CONF_LATITUDE: 1.1, + zone.CONF_LONGITUDE: -2.2 + } + hass.data[zone.DOMAIN] = {} + assert await zone.async_setup_entry(hass, entry) is True + assert await zone.async_unload_entry(hass, entry) is True + assert not hass.data[zone.DOMAIN] class TestComponentZone(unittest.TestCase): @@ -20,18 +52,17 @@ class TestComponentZone(unittest.TestCase): def test_setup_no_zones_still_adds_home_zone(self): """Test if no config is passed in we still get the home zone.""" - assert setup.setup_component(self.hass, zone.DOMAIN, - {'zone': None}) - + assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': None}) assert len(self.hass.states.entity_ids('zone')) == 1 state = self.hass.states.get('zone.home') assert self.hass.config.location_name == state.name assert self.hass.config.latitude == state.attributes['latitude'] assert self.hass.config.longitude == state.attributes['longitude'] assert not state.attributes.get('passive', False) + assert 'test_home' in self.hass.data[zone.DOMAIN] def test_setup(self): - """Test setup.""" + """Test a successful setup.""" info = { 'name': 'Test Zone', 'latitude': 32.880837, @@ -39,16 +70,61 @@ class TestComponentZone(unittest.TestCase): 'radius': 250, 'passive': True } - assert setup.setup_component(self.hass, zone.DOMAIN, { - 'zone': info - }) + assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': info}) + assert len(self.hass.states.entity_ids('zone')) == 2 state = self.hass.states.get('zone.test_zone') assert info['name'] == state.name assert info['latitude'] == state.attributes['latitude'] assert info['longitude'] == state.attributes['longitude'] assert info['radius'] == state.attributes['radius'] assert info['passive'] == state.attributes['passive'] + assert 'test_zone' in self.hass.data[zone.DOMAIN] + assert 'test_home' in self.hass.data[zone.DOMAIN] + + def test_setup_zone_skips_home_zone(self): + """Test that zone named Home should override hass home zone.""" + info = { + 'name': 'Home', + 'latitude': 1.1, + 'longitude': -2.2, + } + assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': info}) + + assert len(self.hass.states.entity_ids('zone')) == 1 + state = self.hass.states.get('zone.home') + assert info['name'] == state.name + assert 'home' in self.hass.data[zone.DOMAIN] + assert 'test_home' not in self.hass.data[zone.DOMAIN] + + def test_setup_registered_zone_skips_home_zone(self): + """Test that config entry named home should override hass home zone.""" + entry = MockConfigEntry(domain=zone.DOMAIN, data={ + zone.CONF_NAME: 'home' + }) + entry.add_to_hass(self.hass) + assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': None}) + assert len(self.hass.states.entity_ids('zone')) == 0 + assert not self.hass.data[zone.DOMAIN] + + def test_setup_registered_zone_skips_configured_zone(self): + """Test if config entry will override configured zone.""" + entry = MockConfigEntry(domain=zone.DOMAIN, data={ + zone.CONF_NAME: 'Test Zone' + }) + entry.add_to_hass(self.hass) + info = { + 'name': 'Test Zone', + 'latitude': 1.1, + 'longitude': -2.2, + } + assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': info}) + + assert len(self.hass.states.entity_ids('zone')) == 1 + state = self.hass.states.get('zone.test_zone') + assert not state + assert 'test_zone' not in self.hass.data[zone.DOMAIN] + assert 'test_home' in self.hass.data[zone.DOMAIN] def test_active_zone_skips_passive_zones(self): """Test active and passive zones.""" @@ -64,7 +140,7 @@ class TestComponentZone(unittest.TestCase): ] }) self.hass.block_till_done() - active = zone.active_zone(self.hass, 32.880600, -117.237561) + active = zone.zone.active_zone(self.hass, 32.880600, -117.237561) assert active is None def test_active_zone_skips_passive_zones_2(self): @@ -80,7 +156,7 @@ class TestComponentZone(unittest.TestCase): ] }) self.hass.block_till_done() - active = zone.active_zone(self.hass, 32.880700, -117.237561) + active = zone.zone.active_zone(self.hass, 32.880700, -117.237561) assert 'zone.active_zone' == active.entity_id def test_active_zone_prefers_smaller_zone_if_same_distance(self): @@ -104,7 +180,7 @@ class TestComponentZone(unittest.TestCase): ] }) - active = zone.active_zone(self.hass, latitude, longitude) + active = zone.zone.active_zone(self.hass, latitude, longitude) assert 'zone.small_zone' == active.entity_id def test_active_zone_prefers_smaller_zone_if_same_distance_2(self): @@ -122,7 +198,7 @@ class TestComponentZone(unittest.TestCase): ] }) - active = zone.active_zone(self.hass, latitude, longitude) + active = zone.zone.active_zone(self.hass, latitude, longitude) assert 'zone.smallest_zone' == active.entity_id def test_in_zone_works_for_passive_zones(self): @@ -141,5 +217,5 @@ class TestComponentZone(unittest.TestCase): ] }) - assert zone.in_zone(self.hass.states.get('zone.passive_zone'), - latitude, longitude) + assert zone.zone.in_zone(self.hass.states.get('zone.passive_zone'), + latitude, longitude) diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index cdbf91d09e5..e608dcccaba 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -13,11 +13,12 @@ from homeassistant.components.binary_sensor.zwave import get_device from homeassistant.components.zwave import ( const, CONFIG_SCHEMA, CONF_DEVICE_CONFIG_GLOB, DATA_NETWORK) from homeassistant.setup import setup_component +from tests.common import mock_registry import pytest from tests.common import ( - get_test_home_assistant, async_fire_time_changed) + get_test_home_assistant, async_fire_time_changed, mock_coro) from tests.mock.zwave import MockNetwork, MockNode, MockValue, MockEntityValues @@ -224,6 +225,48 @@ def test_node_discovery(hass, mock_openzwave): assert hass.states.get('zwave.mock_node').state is 'unknown' +async def test_unparsed_node_discovery(hass, mock_openzwave): + """Test discovery of a node.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == MockNetwork.SIGNAL_NODE_ADDED: + mock_receivers.append(receiver) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + await async_setup_component(hass, 'zwave', {'zwave': {}}) + + assert len(mock_receivers) == 1 + + node = MockNode( + node_id=14, manufacturer_name=None, name=None, is_ready=False) + + sleeps = [] + + def utcnow(): + return datetime.fromtimestamp(len(sleeps)) + + asyncio_sleep = asyncio.sleep + + async def sleep(duration, loop): + if duration > 0: + sleeps.append(duration) + await asyncio_sleep(0, loop=loop) + + with patch('homeassistant.components.zwave.dt_util.utcnow', new=utcnow): + with patch('asyncio.sleep', new=sleep): + with patch.object(zwave, '_LOGGER') as mock_logger: + hass.async_add_job(mock_receivers[0], node) + await hass.async_block_till_done() + + assert len(sleeps) == const.NODE_READY_WAIT_SECS + assert mock_logger.warning.called + assert len(mock_logger.warning.mock_calls) == 1 + assert mock_logger.warning.mock_calls[0][1][1:] == \ + (14, const.NODE_READY_WAIT_SECS) + assert hass.states.get('zwave.unknown_node_14').state is 'unknown' + + @asyncio.coroutine def test_node_ignored(hass, mock_openzwave): """Test discovery of a node.""" @@ -427,6 +470,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): """Initialize values for this testcase class.""" self.hass = get_test_home_assistant() self.hass.start() + self.registry = mock_registry(self.hass) setup_component(self.hass, 'zwave', {'zwave': {}}) self.hass.block_till_done() @@ -446,7 +490,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): const.DISC_OPTIONAL: True, }}} self.primary = MockValue( - command_class='mock_primary_class', node=self.node) + command_class='mock_primary_class', node=self.node, value_id=1000) self.secondary = MockValue( command_class='mock_secondary_class', node=self.node) self.duplicate_secondary = MockValue( @@ -468,6 +512,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): @patch.object(zwave, 'discovery') def test_entity_discovery(self, discovery, get_platform): """Test the creation of a new entity.""" + discovery.async_load_platform.return_value = mock_coro() mock_platform = MagicMock() get_platform.return_value = mock_platform mock_device = MagicMock() @@ -479,6 +524,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) assert values.primary is self.primary @@ -500,8 +546,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): key=lambda a: id(a))) assert discovery.async_load_platform.called - # Second call is to async yield from - assert len(discovery.async_load_platform.mock_calls) == 2 + assert len(discovery.async_load_platform.mock_calls) == 1 args = discovery.async_load_platform.mock_calls[0][1] assert args[0] == self.hass assert args[1] == 'mock_component' @@ -532,6 +577,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): @patch.object(zwave, 'discovery') def test_entity_existing_values(self, discovery, get_platform): """Test the loading of already discovered values.""" + discovery.async_load_platform.return_value = mock_coro() mock_platform = MagicMock() get_platform.return_value = mock_platform mock_device = MagicMock() @@ -550,6 +596,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) self.hass.block_till_done() @@ -563,8 +610,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): key=lambda a: id(a))) assert discovery.async_load_platform.called - # Second call is to async yield from - assert len(discovery.async_load_platform.mock_calls) == 2 + assert len(discovery.async_load_platform.mock_calls) == 1 args = discovery.async_load_platform.mock_calls[0][1] assert args[0] == self.hass assert args[1] == 'mock_component' @@ -589,6 +635,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) values._check_entity_ready() self.hass.block_till_done() @@ -598,7 +645,8 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): @patch.object(zwave, 'get_platform') @patch.object(zwave, 'discovery') def test_entity_workaround_component(self, discovery, get_platform): - """Test ignore workaround.""" + """Test component workaround.""" + discovery.async_load_platform.return_value = mock_coro() mock_platform = MagicMock() get_platform.return_value = mock_platform mock_device = MagicMock() @@ -624,13 +672,13 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) values._check_entity_ready() self.hass.block_till_done() assert discovery.async_load_platform.called - # Second call is to async yield from - assert len(discovery.async_load_platform.mock_calls) == 2 + assert len(discovery.async_load_platform.mock_calls) == 1 args = discovery.async_load_platform.mock_calls[0][1] assert args[1] == 'binary_sensor' @@ -656,6 +704,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) values._check_entity_ready() self.hass.block_till_done() @@ -679,12 +728,42 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) values._check_entity_ready() self.hass.block_till_done() assert not discovery.async_load_platform.called + @patch.object(zwave, 'get_platform') + @patch.object(zwave, 'discovery') + def test_entity_config_ignore_with_registry(self, discovery, get_platform): + """Test ignore config. + + The case when the device is in entity registry. + """ + self.node.values = { + self.primary.value_id: self.primary, + self.secondary.value_id: self.secondary, + } + self.device_config = {'mock_component.registry_id': { + zwave.CONF_IGNORED: True + }} + self.registry.async_get_or_create( + 'mock_component', zwave.DOMAIN, '567-1000', + suggested_object_id='registry_id') + zwave.ZWaveDeviceEntityValues( + hass=self.hass, + schema=self.mock_schema, + primary_value=self.primary, + zwave_config=self.zwave_config, + device_config=self.device_config, + registry=self.registry + ) + self.hass.block_till_done() + + assert not discovery.async_load_platform.called + @patch.object(zwave, 'get_platform') @patch.object(zwave, 'discovery') def test_entity_platform_ignore(self, discovery, get_platform): @@ -702,6 +781,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) self.hass.block_till_done() @@ -729,6 +809,7 @@ class TestZWaveDeviceEntityValues(unittest.TestCase): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) values._check_entity_ready() self.hass.block_till_done() @@ -995,8 +1076,21 @@ class TestZWaveServices(unittest.TestCase): type=const.TYPE_LIST, data_items=['item1', 'item2', 'item3'], ) + value_list_int = MockValue( + index=15, + command_class=const.COMMAND_CLASS_CONFIGURATION, + type=const.TYPE_LIST, + data_items=['1', '2', '3'], + ) + value_button = MockValue( + index=14, + command_class=const.COMMAND_CLASS_CONFIGURATION, + type=const.TYPE_BUTTON, + ) node = MockNode(node_id=14) - node.get_values.return_value = {12: value, 13: value_list} + node.get_values.return_value = {12: value, 13: value_list, + 14: value_button, + 15: value_list_int} self.zwave_network.nodes = {14: node} self.hass.services.call('zwave', 'set_config_parameter', { @@ -1008,6 +1102,15 @@ class TestZWaveServices(unittest.TestCase): assert value_list.data == 'item3' + self.hass.services.call('zwave', 'set_config_parameter', { + const.ATTR_NODE_ID: 14, + const.ATTR_CONFIG_PARAMETER: 15, + const.ATTR_CONFIG_VALUE: 3, + }) + self.hass.block_till_done() + + assert value_list_int.data == '3' + self.hass.services.call('zwave', 'set_config_parameter', { const.ATTR_NODE_ID: 14, const.ATTR_CONFIG_PARAMETER: 12, @@ -1017,6 +1120,16 @@ class TestZWaveServices(unittest.TestCase): assert value.data == 7 + self.hass.services.call('zwave', 'set_config_parameter', { + const.ATTR_NODE_ID: 14, + const.ATTR_CONFIG_PARAMETER: 14, + const.ATTR_CONFIG_VALUE: True, + }) + self.hass.block_till_done() + + assert self.zwave_network.manager.pressButton.called + assert self.zwave_network.manager.releaseButton.called + self.hass.services.call('zwave', 'set_config_parameter', { const.ATTR_NODE_ID: 14, const.ATTR_CONFIG_PARAMETER: 19, @@ -1062,20 +1175,18 @@ class TestZWaveServices(unittest.TestCase): assert mock_logger.info.mock_calls[0][1][3] == 2345 def test_print_node(self): - """Test zwave print_config_parameter service.""" - node1 = MockNode(node_id=14) - node2 = MockNode(node_id=15) - self.zwave_network.nodes = {14: node1, 15: node2} + """Test zwave print_node_parameter service.""" + node = MockNode(node_id=14) - with patch.object(zwave, 'pprint') as mock_pprint: + self.zwave_network.nodes = {14: node} + + with self.assertLogs(level='INFO') as mock_logger: self.hass.services.call('zwave', 'print_node', { - const.ATTR_NODE_ID: 15, + const.ATTR_NODE_ID: 14 }) self.hass.block_till_done() - assert mock_pprint.called - assert len(mock_pprint.mock_calls) == 1 - assert mock_pprint.mock_calls[0][1][0]['node_id'] == 15 + self.assertIn("FOUND NODE ", mock_logger.output[1]) def test_set_wakeup(self): """Test zwave set_wakeup service.""" diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index 299821d3685..b91245d5a12 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -182,8 +182,6 @@ class TestZWaveNodeEntity(unittest.TestCase): query_stage='Dynamic', is_awake=True, is_ready=False, is_failed=False, is_info_received=True, max_baud_rate=40000, is_zwave_plus=False, capabilities=[], neighbors=[], location=None) - self.node.manufacturer_name = 'Test Manufacturer' - self.node.product_name = 'Test Product' self.entity = node_entity.ZWaveNodeEntity(self.node, self.zwave_network) @@ -357,3 +355,15 @@ class TestZWaveNodeEntity(unittest.TestCase): def test_not_polled(self): """Test should_poll property.""" self.assertFalse(self.entity.should_poll) + + def test_unique_id(self): + """Test unique_id.""" + self.assertEqual('node-567', self.entity.unique_id) + + def test_unique_id_missing_data(self): + """Test unique_id.""" + self.node.manufacturer_name = None + self.node.name = None + entity = node_entity.ZWaveNodeEntity(self.node, self.zwave_network) + + self.assertIsNone(entity.unique_id) diff --git a/tests/conftest.py b/tests/conftest.py index 8f0ca787721..4d619c5ef61 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,11 +20,11 @@ if os.environ.get('UVLOOP') == '1': import uvloop asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) -logging.basicConfig() +logging.basicConfig(level=logging.INFO) logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) -def test_real(func): +def check_real(func): """Force a function to require a keyword _test_real to be passed in.""" @functools.wraps(func) def guard_func(*args, **kwargs): @@ -40,8 +40,8 @@ def test_real(func): # Guard a few functions that would make network connections -location.detect_location_info = test_real(location.detect_location_info) -location.elevation = test_real(location.elevation) +location.detect_location_info = check_real(location.detect_location_info) +location.elevation = check_real(location.elevation) util.get_local_ip = lambda: '127.0.0.1' @@ -123,7 +123,5 @@ def mock_device_tracker_conf(): ), patch( 'homeassistant.components.device_tracker.async_load_config', side_effect=lambda *args: mock_coro(devices) - ), patch('homeassistant.components.device_tracker' - '.Device.set_vendor_for_mac'): - + ): yield devices diff --git a/tests/fixtures/bom_weather.json b/tests/fixtures/bom_weather.json new file mode 100644 index 00000000000..d40ea6fb21a --- /dev/null +++ b/tests/fixtures/bom_weather.json @@ -0,0 +1,42 @@ +{ + "observations": { + "data": [ + { + "wmo": 94767, + "name": "Fake", + "history_product": "IDN00000", + "local_date_time_full": "20180422130000", + "apparent_t": 25.0, + "press": 1021.7, + "weather": "-" + }, + { + "wmo": 94767, + "name": "Fake", + "history_product": "IDN00000", + "local_date_time_full": "20180422130000", + "apparent_t": 22.0, + "press": 1019.7, + "weather": "-" + }, + { + "wmo": 94767, + "name": "Fake", + "history_product": "IDN00000", + "local_date_time_full": "20180422130000", + "apparent_t": 20.0, + "press": 1011.7, + "weather": "Fine" + }, + { + "wmo": 94767, + "name": "Fake", + "history_product": "IDN00000", + "local_date_time_full": "20180422130000", + "apparent_t": 18.0, + "press": 1010.0, + "weather": "-" + } + ] + } +} diff --git a/tests/fixtures/coinmarketcap.json b/tests/fixtures/coinmarketcap.json index 20f5e4fe91e..5a6b63c5da1 100644 --- a/tests/fixtures/coinmarketcap.json +++ b/tests/fixtures/coinmarketcap.json @@ -1,21 +1,36 @@ -[ - { - "id": "ethereum", - "name": "Ethereum", - "symbol": "ETH", - "rank": "2", - "price_usd": "282.423", - "price_btc": "0.048844", - "24h_volume_usd": "407024000.0", - "market_cap_usd": "26908205315.0", - "available_supply": "95276253.0", - "total_supply": "95276253.0", - "percent_change_1h": "0.06", - "percent_change_24h": "-4.57", - "percent_change_7d": "-16.39", - "last_updated": "1508776751", - "price_eur": "240.473299695", - "24h_volume_eur": "346566690.16", - "market_cap_eur": "22911395039.0" +{ + "cached": false, + "data": { + "id": 1027, + "name": "Ethereum", + "symbol": "ETH", + "website_slug": "ethereum", + "rank": 2, + "circulating_supply": 99619842.0, + "total_supply": 99619842.0, + "max_supply": null, + "quotes": { + "USD": { + "price": 577.019, + "volume_24h": 2839960000.0, + "market_cap": 57482541899.0, + "percent_change_1h": -2.28, + "percent_change_24h": -14.88, + "percent_change_7d": -17.51 + }, + "EUR": { + "price": 493.454724572, + "volume_24h": 2428699712.48, + "market_cap": 49158380042.0, + "percent_change_1h": -2.28, + "percent_change_24h": -14.88, + "percent_change_7d": -17.51 + } + }, + "last_updated": 1527098658 + }, + "metadata": { + "timestamp": 1527098716, + "error": null } -] \ No newline at end of file +} \ No newline at end of file diff --git a/tests/fixtures/feedreader.xml b/tests/fixtures/feedreader.xml new file mode 100644 index 00000000000..8c85a4975ee --- /dev/null +++ b/tests/fixtures/feedreader.xml @@ -0,0 +1,20 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +1000 + Mon, 30 Apr 2018 15:00:00 +1000 + 1800 + + + Title 1 + Description 1 + http://www.example.com/link/1 + GUID 1 + Mon, 30 Apr 2018 15:10:00 +1000 + + + + diff --git a/tests/fixtures/feedreader1.xml b/tests/fixtures/feedreader1.xml new file mode 100644 index 00000000000..ff856125779 --- /dev/null +++ b/tests/fixtures/feedreader1.xml @@ -0,0 +1,27 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +1000 + Mon, 30 Apr 2018 15:00:00 +1000 + 1800 + + + Title 1 + Description 1 + http://www.example.com/link/1 + GUID 1 + Mon, 30 Apr 2018 15:10:00 +1000 + + + Title 2 + Description 2 + http://www.example.com/link/2 + GUID 2 + Mon, 30 Apr 2018 15:11:00 +1000 + + + + diff --git a/tests/fixtures/feedreader2.xml b/tests/fixtures/feedreader2.xml new file mode 100644 index 00000000000..653a16e4561 --- /dev/null +++ b/tests/fixtures/feedreader2.xml @@ -0,0 +1,97 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +1000 + Mon, 30 Apr 2018 15:00:00 +1000 + 1800 + + + Title 1 + Mon, 30 Apr 2018 15:00:00 +1000 + + + Title 2 + Mon, 30 Apr 2018 15:01:00 +1000 + + + Title 3 + Mon, 30 Apr 2018 15:02:00 +1000 + + + Title 4 + Mon, 30 Apr 2018 15:03:00 +1000 + + + Title 5 + Mon, 30 Apr 2018 15:04:00 +1000 + + + Title 6 + Mon, 30 Apr 2018 15:05:00 +1000 + + + Title 7 + Mon, 30 Apr 2018 15:06:00 +1000 + + + Title 8 + Mon, 30 Apr 2018 15:07:00 +1000 + + + Title 9 + Mon, 30 Apr 2018 15:08:00 +1000 + + + Title 10 + Mon, 30 Apr 2018 15:09:00 +1000 + + + Title 11 + Mon, 30 Apr 2018 15:10:00 +1000 + + + Title 12 + Mon, 30 Apr 2018 15:11:00 +1000 + + + Title 13 + Mon, 30 Apr 2018 15:12:00 +1000 + + + Title 14 + Mon, 30 Apr 2018 15:13:00 +1000 + + + Title 15 + Mon, 30 Apr 2018 15:14:00 +1000 + + + Title 16 + Mon, 30 Apr 2018 15:15:00 +1000 + + + Title 17 + Mon, 30 Apr 2018 15:16:00 +1000 + + + Title 18 + Mon, 30 Apr 2018 15:17:00 +1000 + + + Title 19 + Mon, 30 Apr 2018 15:18:00 +1000 + + + Title 20 + Mon, 30 Apr 2018 15:19:00 +1000 + + + Title 21 + Mon, 30 Apr 2018 15:20:00 +1000 + + + + diff --git a/tests/fixtures/feedreader3.xml b/tests/fixtures/feedreader3.xml new file mode 100644 index 00000000000..7b28e067cfe --- /dev/null +++ b/tests/fixtures/feedreader3.xml @@ -0,0 +1,26 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +1000 + Mon, 30 Apr 2018 15:00:00 +1000 + 1800 + + + Title 1 + Description 1 + http://www.example.com/link/1 + GUID 1 + Mon, 30 Apr 2018 15:10:00 +1000 + + + Title 2 + Description 2 + http://www.example.com/link/2 + GUID 2 + + + + diff --git a/tests/fixtures/foobot_data.json b/tests/fixtures/foobot_data.json new file mode 100644 index 00000000000..93518614c42 --- /dev/null +++ b/tests/fixtures/foobot_data.json @@ -0,0 +1,34 @@ +{ + "uuid": "32463564765421243", + "start": 1518134963, + "end": 1518134963, + "sensors": [ + "time", + "pm", + "tmp", + "hum", + "co2", + "voc", + "allpollu" + ], + "units": [ + "s", + "ugm3", + "C", + "pc", + "ppm", + "ppb", + "%" + ], + "datapoints": [ + [ + 1518134963, + 144.76668, + 21.064333, + 49.474, + 1232.0, + 340.66666, + 138.93651 + ] + ] +} diff --git a/tests/fixtures/foobot_devices.json b/tests/fixtures/foobot_devices.json new file mode 100644 index 00000000000..fffc8e151cc --- /dev/null +++ b/tests/fixtures/foobot_devices.json @@ -0,0 +1,8 @@ +[ + { + "uuid": "231425657665645342", + "userId": 6545342, + "mac": "A2D3F1", + "name": "Happybot" + } +] diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index abe30d80a49..28bb31c8482 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -14,7 +14,7 @@ from tests.common import get_test_home_assistant @pytest.fixture -def camera_client(hass, test_client): +def camera_client(hass, aiohttp_client): """Fixture to fetch camera streams.""" assert hass.loop.run_until_complete(async_setup_component(hass, 'camera', { 'camera': { @@ -23,7 +23,7 @@ def camera_client(hass, test_client): 'mjpeg_url': 'http://example.com/mjpeg_stream', }})) - yield hass.loop.run_until_complete(test_client(hass.http.app)) + yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) class TestHelpersAiohttpClient(unittest.TestCase): diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 90be56bbc7c..28efcb3e868 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -10,8 +10,6 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv -from tests.common import get_test_home_assistant - def test_boolean(): """Test boolean validation.""" @@ -256,24 +254,6 @@ def test_event_schema(): cv.EVENT_SCHEMA(value) -def test_platform_validator(): - """Test platform validation.""" - hass = None - - try: - hass = get_test_home_assistant() - - schema = vol.Schema(cv.platform_validator('light')) - - with pytest.raises(vol.MultipleInvalid): - schema('platform_that_does_not_exist') - - schema('hue') - finally: - if hass is not None: - hass.stop() - - def test_icon(): """Test icon validation.""" schema = vol.Schema(cv.icon) @@ -585,3 +565,31 @@ def test_socket_timeout(): # pylint: disable=invalid-name assert _GLOBAL_DEFAULT_TIMEOUT == schema(None) assert schema(1) == 1.0 + + +def test_matches_regex(): + """Test matches_regex validator.""" + schema = vol.Schema(cv.matches_regex('.*uiae.*')) + + with pytest.raises(vol.Invalid): + schema(1.0) + + with pytest.raises(vol.Invalid): + schema(" nrtd ") + + test_str = "This is a test including uiae." + assert(schema(test_str) == test_str) + + +def test_is_regex(): + """Test the is_regex validator.""" + schema = vol.Schema(cv.is_regex) + + with pytest.raises(vol.Invalid): + schema("(") + + with pytest.raises(vol.Invalid): + schema({"a dict": "is not a regex"}) + + valid_re = ".*" + schema(valid_re) diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index 2087dc2adb5..c7b39954d85 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -1,5 +1,4 @@ """Test discovery helpers.""" -import asyncio from unittest.mock import patch import pytest @@ -24,7 +23,8 @@ class TestHelpersDiscovery: """Stop everything that was started.""" self.hass.stop() - @patch('homeassistant.setup.async_setup_component') + @patch('homeassistant.setup.async_setup_component', + return_value=mock_coro()) def test_listen(self, mock_setup_component): """Test discovery listen/discover combo.""" helpers = self.hass.helpers @@ -129,11 +129,11 @@ class TestHelpersDiscovery: platform_calls.append('disc' if discovery_info else 'component') loader.set_component( - 'test_component', + self.hass, 'test_component', MockModule('test_component', setup=component_setup)) loader.set_component( - 'switch.test_circular', + self.hass, 'switch.test_circular', MockPlatform(setup_platform, dependencies=['test_component'])) @@ -177,11 +177,11 @@ class TestHelpersDiscovery: return True loader.set_component( - 'test_component1', + self.hass, 'test_component1', MockModule('test_component1', setup=component1_setup)) loader.set_component( - 'test_component2', + self.hass, 'test_component2', MockModule('test_component2', setup=component2_setup)) @callback @@ -199,15 +199,13 @@ class TestHelpersDiscovery: assert len(component_calls) == 1 -@asyncio.coroutine -def test_load_platform_forbids_config(): +async def test_load_platform_forbids_config(): """Test you cannot setup config component with load_platform.""" with pytest.raises(HomeAssistantError): - yield from discovery.async_load_platform(None, 'config', 'zwave') + await discovery.async_load_platform(None, 'config', 'zwave') -@asyncio.coroutine -def test_discover_forbids_config(): +async def test_discover_forbids_config(): """Test you cannot setup config component with load_platform.""" with pytest.raises(HomeAssistantError): - yield from discovery.async_discover(None, None, None, 'config') + await discovery.async_discover(None, None, None, 'config') diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index d8dac11f6a0..504f31cc987 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -7,6 +7,8 @@ import unittest from unittest.mock import patch, Mock from datetime import timedelta +import pytest + import homeassistant.core as ha import homeassistant.loader as loader from homeassistant.exceptions import PlatformNotReady @@ -19,7 +21,7 @@ import homeassistant.util.dt as dt_util from tests.common import ( get_test_home_assistant, MockPlatform, MockModule, mock_coro, - async_fire_time_changed, MockEntity) + async_fire_time_changed, MockEntity, MockConfigEntry) _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" @@ -73,9 +75,9 @@ class TestHelpersEntityComponent(unittest.TestCase): component_setup = Mock(return_value=True) platform_setup = Mock(return_value=None) loader.set_component( - 'test_component', + self.hass, 'test_component', MockModule('test_component', setup=component_setup)) - loader.set_component('test_domain.mod2', + loader.set_component(self.hass, 'test_domain.mod2', MockPlatform(platform_setup, ['test_component'])) component = EntityComponent(_LOGGER, DOMAIN, self.hass) @@ -98,8 +100,10 @@ class TestHelpersEntityComponent(unittest.TestCase): platform1_setup = Mock(side_effect=Exception('Broken')) platform2_setup = Mock(return_value=None) - loader.set_component('test_domain.mod1', MockPlatform(platform1_setup)) - loader.set_component('test_domain.mod2', MockPlatform(platform2_setup)) + loader.set_component(self.hass, 'test_domain.mod1', + MockPlatform(platform1_setup)) + loader.set_component(self.hass, 'test_domain.mod2', + MockPlatform(platform2_setup)) component = EntityComponent(_LOGGER, DOMAIN, self.hass) @@ -143,7 +147,7 @@ class TestHelpersEntityComponent(unittest.TestCase): """Test the platform setup.""" add_devices([MockEntity(should_poll=True)]) - loader.set_component('test_domain.platform', + loader.set_component(self.hass, 'test_domain.platform', MockPlatform(platform_setup)) component = EntityComponent(_LOGGER, DOMAIN, self.hass) @@ -170,7 +174,7 @@ class TestHelpersEntityComponent(unittest.TestCase): platform = MockPlatform(platform_setup) - loader.set_component('test_domain.platform', platform) + loader.set_component(self.hass, 'test_domain.platform', platform) component = EntityComponent(_LOGGER, DOMAIN, self.hass) @@ -218,7 +222,8 @@ def test_platform_not_ready(hass): """Test that we retry when platform not ready.""" platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None]) - loader.set_component('test_domain.mod1', MockPlatform(platform1_setup)) + loader.set_component(hass, 'test_domain.mod1', + MockPlatform(platform1_setup)) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -314,10 +319,11 @@ def test_setup_dependencies_platform(hass): We're explictely testing that we process dependencies even if a component with the same name has already been loaded. """ - loader.set_component('test_component', MockModule('test_component')) - loader.set_component('test_component2', MockModule('test_component2')) + loader.set_component(hass, 'test_component', MockModule('test_component')) + loader.set_component(hass, 'test_component2', + MockModule('test_component2')) loader.set_component( - 'test_domain.test_component', + hass, 'test_domain.test_component', MockPlatform(dependencies=['test_component', 'test_component2'])) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -333,3 +339,75 @@ def test_setup_dependencies_platform(hass): assert 'test_component' in hass.config.components assert 'test_component2' in hass.config.components assert 'test_domain.test_component' in hass.config.components + + +async def test_setup_entry(hass): + """Test setup entry calls async_setup_entry on platform.""" + mock_setup_entry = Mock(return_value=mock_coro(True)) + loader.set_component( + hass, 'test_domain.entry_domain', + MockPlatform(async_setup_entry=mock_setup_entry)) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='entry_domain') + + assert await component.async_setup_entry(entry) + assert len(mock_setup_entry.mock_calls) == 1 + p_hass, p_entry, p_add_entities = mock_setup_entry.mock_calls[0][1] + assert p_hass is hass + assert p_entry is entry + + +async def test_setup_entry_platform_not_exist(hass): + """Test setup entry fails if platform doesnt exist.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='non_existing') + + assert (await component.async_setup_entry(entry)) is False + + +async def test_setup_entry_fails_duplicate(hass): + """Test we don't allow setting up a config entry twice.""" + mock_setup_entry = Mock(return_value=mock_coro(True)) + loader.set_component( + hass, 'test_domain.entry_domain', + MockPlatform(async_setup_entry=mock_setup_entry)) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='entry_domain') + + assert await component.async_setup_entry(entry) + + with pytest.raises(ValueError): + await component.async_setup_entry(entry) + + +async def test_unload_entry_resets_platform(hass): + """Test unloading an entry removes all entities.""" + mock_setup_entry = Mock(return_value=mock_coro(True)) + loader.set_component( + hass, 'test_domain.entry_domain', + MockPlatform(async_setup_entry=mock_setup_entry)) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='entry_domain') + + assert await component.async_setup_entry(entry) + assert len(mock_setup_entry.mock_calls) == 1 + add_entities = mock_setup_entry.mock_calls[0][1][2] + add_entities([MockEntity()]) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + assert await component.async_unload_entry(entry) + assert len(hass.states.async_entity_ids()) == 0 + + +async def test_unload_entry_fails_if_never_loaded(hass): + """.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='entry_domain') + + with pytest.raises(ValueError): + await component.async_unload_entry(entry) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 8c085e4abb1..4e09f9576f2 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -5,6 +5,7 @@ import unittest from unittest.mock import patch, Mock, MagicMock from datetime import timedelta +from homeassistant.exceptions import PlatformNotReady import homeassistant.loader as loader from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_component import ( @@ -15,7 +16,7 @@ import homeassistant.util.dt as dt_util from tests.common import ( get_test_home_assistant, MockPlatform, fire_time_changed, mock_registry, - MockEntity, MockEntityPlatform) + MockEntity, MockEntityPlatform, MockConfigEntry, mock_coro) _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" @@ -146,7 +147,7 @@ class TestHelpersEntityPlatform(unittest.TestCase): platform = MockPlatform(platform_setup) platform.SCAN_INTERVAL = timedelta(seconds=30) - loader.set_component('test_domain.platform', platform) + loader.set_component(self.hass, 'test_domain.platform', platform) component = EntityComponent(_LOGGER, DOMAIN, self.hass) @@ -183,7 +184,7 @@ def test_platform_warn_slow_setup(hass): """Warn we log when platform setup takes a long time.""" platform = MockPlatform() - loader.set_component('test_domain.platform', platform) + loader.set_component(hass, 'test_domain.platform', platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -217,7 +218,7 @@ def test_platform_error_slow_setup(hass, caplog): platform = MockPlatform(async_setup_platform=setup_platform) component = EntityComponent(_LOGGER, DOMAIN, hass) - loader.set_component('test_domain.test_platform', platform) + loader.set_component(hass, 'test_domain.test_platform', platform) yield from component.async_setup({ DOMAIN: { 'platform': 'test_platform', @@ -259,7 +260,7 @@ def test_parallel_updates_async_platform(hass): platform.async_setup_platform = mock_update - loader.set_component('test_domain.platform', platform) + loader.set_component(hass, 'test_domain.platform', platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -287,7 +288,7 @@ def test_parallel_updates_async_platform_with_constant(hass): platform.async_setup_platform = mock_update platform.PARALLEL_UPDATES = 1 - loader.set_component('test_domain.platform', platform) + loader.set_component(hass, 'test_domain.platform', platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -308,7 +309,7 @@ def test_parallel_updates_sync_platform(hass): """Warn we log when platform setup takes a long time.""" platform = MockPlatform(setup_platform=lambda *args: None) - loader.set_component('test_domain.platform', platform) + loader.set_component(hass, 'test_domain.platform', platform) component = EntityComponent(_LOGGER, DOMAIN, hass) component._platforms = {} @@ -511,3 +512,72 @@ async def test_entity_registry_updates(hass): state = hass.states.get('test_domain.world') assert state.name == 'after update' + + +async def test_setup_entry(hass): + """Test we can setup an entry.""" + async_setup_entry = Mock(return_value=mock_coro(True)) + platform = MockPlatform( + async_setup_entry=async_setup_entry + ) + config_entry = MockConfigEntry() + entity_platform = MockEntityPlatform( + hass, + platform_name=config_entry.domain, + platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + + full_name = '{}.{}'.format(entity_platform.domain, config_entry.domain) + assert full_name in hass.config.components + assert len(async_setup_entry.mock_calls) == 1 + + +async def test_setup_entry_platform_not_ready(hass, caplog): + """Test when an entry is not ready yet.""" + async_setup_entry = Mock(side_effect=PlatformNotReady) + platform = MockPlatform( + async_setup_entry=async_setup_entry + ) + config_entry = MockConfigEntry() + ent_platform = MockEntityPlatform( + hass, + platform_name=config_entry.domain, + platform=platform + ) + + with patch.object(entity_platform, 'async_call_later') as mock_call_later: + assert not await ent_platform.async_setup_entry(config_entry) + + full_name = '{}.{}'.format(ent_platform.domain, config_entry.domain) + assert full_name not in hass.config.components + assert len(async_setup_entry.mock_calls) == 1 + assert 'Platform test not ready yet' in caplog.text + assert len(mock_call_later.mock_calls) == 1 + + +async def test_reset_cancels_retry_setup(hass): + """Test that resetting a platform will cancel scheduled a setup retry.""" + async_setup_entry = Mock(side_effect=PlatformNotReady) + platform = MockPlatform( + async_setup_entry=async_setup_entry + ) + config_entry = MockConfigEntry() + ent_platform = MockEntityPlatform( + hass, + platform_name=config_entry.domain, + platform=platform + ) + + with patch.object(entity_platform, 'async_call_later') as mock_call_later: + assert not await ent_platform.async_setup_entry(config_entry) + + assert len(mock_call_later.mock_calls) == 1 + assert len(mock_call_later.return_value.mock_calls) == 0 + assert ent_platform._async_cancel_retry_setup is not None + + await ent_platform.async_reset() + + assert len(mock_call_later.return_value.mock_calls) == 1 + assert ent_platform._async_cancel_retry_setup is None diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index cb8703d1fe6..492b97f6387 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -180,3 +180,13 @@ test.disabled_hass: assert entry_disabled_hass.disabled_by == entity_registry.DISABLED_HASS assert entry_disabled_user.disabled assert entry_disabled_user.disabled_by == entity_registry.DISABLED_USER + + +@asyncio.coroutine +def test_async_get_entity_id(registry): + """Test that entity_id is returned.""" + entry = registry.async_get_or_create('light', 'hue', '1234') + assert entry.entity_id == 'light.hue_1234' + assert registry.async_get_entity_id( + 'light', 'hue', '1234') == 'light.hue_1234' + assert registry.async_get_entity_id('light', 'hue', '123') is None diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index a8ae20ad69b..4297ca26e7d 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -218,6 +218,32 @@ class TestScriptHelper(unittest.TestCase): assert not script_obj.is_running assert len(events) == 2 + def test_delay_invalid_template(self): + """Test the delay as a template that fails.""" + event = 'test_event' + events = [] + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + self.hass.bus.listen(event, record_event) + + script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ + {'event': event}, + {'delay': '{{ invalid_delay }}'}, + {'delay': {'seconds': 5}}, + {'event': event}])) + + with mock.patch.object(script, '_LOGGER') as mock_logger: + script_obj.run() + self.hass.block_till_done() + assert mock_logger.error.called + + assert not script_obj.is_running + assert len(events) == 1 + def test_cancel_while_delay(self): """Test the cancelling while the delay is present.""" event = 'test_event' diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index a987f5130f1..79054726c03 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -138,7 +138,7 @@ class TestServiceHelpers(unittest.TestCase): self.hass.states.set('light.Ceiling', STATE_OFF) self.hass.states.set('light.Kitchen', STATE_OFF) - loader.get_component('group').Group.create_group( + loader.get_component(self.hass, 'group').Group.create_group( self.hass, 'test', ['light.Ceiling', 'light.Kitchen']) call = ha.ServiceCall('light', 'turn_on', @@ -160,7 +160,7 @@ class TestServiceHelpers(unittest.TestCase): @asyncio.coroutine def test_async_get_all_descriptions(hass): """Test async_get_all_descriptions.""" - group = loader.get_component('group') + group = loader.get_component(hass, 'group') group_config = {group.DOMAIN: {}} yield from async_setup_component(hass, group.DOMAIN, group_config) descriptions = yield from service.async_get_all_descriptions(hass) @@ -170,7 +170,7 @@ def test_async_get_all_descriptions(hass): assert 'description' in descriptions['group']['reload'] assert 'fields' in descriptions['group']['reload'] - logger = loader.get_component('logger') + logger = loader.get_component(hass, 'logger') logger_config = {logger.DOMAIN: {}} yield from async_setup_component(hass, logger.DOMAIN, logger_config) descriptions = yield from service.async_get_all_descriptions(hass) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 47e46bae3c7..2dfcb2a58e5 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -149,6 +149,74 @@ class TestHelpersTemplate(unittest.TestCase): '{{ log(%s, %s) | round(1) }}' % (value, base), self.hass).render()) + def test_sine(self): + """Test sine.""" + tests = [ + (0, '0.0'), + (math.pi / 2, '1.0'), + (math.pi, '0.0'), + (math.pi * 1.5, '-1.0'), + (math.pi / 10, '0.309') + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | sin | round(3) }}' % value, + self.hass).render()) + + def test_cos(self): + """Test cosine.""" + tests = [ + (0, '1.0'), + (math.pi / 2, '0.0'), + (math.pi, '-1.0'), + (math.pi * 1.5, '-0.0'), + (math.pi / 10, '0.951') + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | cos | round(3) }}' % value, + self.hass).render()) + + def test_tan(self): + """Test tangent.""" + tests = [ + (0, '0.0'), + (math.pi, '-0.0'), + (math.pi / 180 * 45, '1.0'), + (math.pi / 180 * 90, '1.633123935319537e+16'), + (math.pi / 180 * 135, '-1.0') + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | tan | round(3) }}' % value, + self.hass).render()) + + def test_sqrt(self): + """Test square root.""" + tests = [ + (0, '0.0'), + (1, '1.0'), + (2, '1.414'), + (10, '3.162'), + (100, '10.0'), + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | sqrt | round(3) }}' % value, + self.hass).render()) + def test_strptime(self): """Test the parse timestamp method.""" tests = [ @@ -397,6 +465,19 @@ class TestHelpersTemplate(unittest.TestCase): """, self.hass) self.assertEqual('False', tpl.render()) + def test_state_attr(self): + """Test state_attr method.""" + self.hass.states.set('test.object', 'available', {'mode': 'on'}) + tpl = template.Template(""" +{% if state_attr("test.object", "mode") == "on" %}yes{% else %}no{% endif %} + """, self.hass) + self.assertEqual('yes', tpl.render()) + + tpl = template.Template(""" +{{ state_attr("test.noobject", "mode") == None }} + """, self.hass) + self.assertEqual('True', tpl.render()) + def test_states_function(self): """Test using states as a function.""" self.hass.states.set('test.object', 'available') @@ -428,6 +509,59 @@ class TestHelpersTemplate(unittest.TestCase): template.Template('{{ utcnow().isoformat() }}', self.hass).render()) + def test_regex_match(self): + """Test regex_match method.""" + tpl = template.Template(""" +{{ '123-456-7890' | regex_match('(\d{3})-(\d{3})-(\d{4})') }} + """, self.hass) + self.assertEqual('True', tpl.render()) + + tpl = template.Template(""" +{{ 'home assistant test' | regex_match('Home', True) }} + """, self.hass) + self.assertEqual('True', tpl.render()) + + tpl = template.Template(""" + {{ 'Another home assistant test' | regex_match('home') }} + """, self.hass) + self.assertEqual('False', tpl.render()) + + def test_regex_search(self): + """Test regex_search method.""" + tpl = template.Template(""" +{{ '123-456-7890' | regex_search('(\d{3})-(\d{3})-(\d{4})') }} + """, self.hass) + self.assertEqual('True', tpl.render()) + + tpl = template.Template(""" +{{ 'home assistant test' | regex_search('Home', True) }} + """, self.hass) + self.assertEqual('True', tpl.render()) + + tpl = template.Template(""" + {{ 'Another home assistant test' | regex_search('home') }} + """, self.hass) + self.assertEqual('True', tpl.render()) + + def test_regex_replace(self): + """Test regex_replace method.""" + tpl = template.Template(""" +{{ 'Hello World' | regex_replace('(Hello\s)',) }} + """, self.hass) + self.assertEqual('World', tpl.render()) + + def test_regex_findall_index(self): + """Test regex_findall_index method.""" + tpl = template.Template(""" +{{ 'Flight from JFK to LHR' | regex_findall_index('([A-Z]{3})', 0) }} + """, self.hass) + self.assertEqual('JFK', tpl.render()) + + tpl = template.Template(""" +{{ 'Flight from JFK to LHR' | regex_findall_index('([A-Z]{3})', 1) }} + """, self.hass) + self.assertEqual('LHR', tpl.render()) + def test_distance_function_with_1_state(self): """Test distance function with 1 state.""" self.hass.states.set('test.object', 'happy', { @@ -836,6 +970,12 @@ is_state_attr('device_tracker.phone_2', 'battery', 40) "{{ is_state(trigger.entity_id, 'off') }}", {'trigger': {'entity_id': 'input_boolean.switch'}})) + self.assertEqual( + MATCH_ALL, + template.extract_entities( + "{{ is_state('media_player.' ~ where , 'playing') }}", + {'where': 'livingroom'})) + @asyncio.coroutine def test_state_with_unit(hass): diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 840f665f410..99c6f7dddf1 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -1,11 +1,23 @@ """Test the translation helper.""" # pylint: disable=protected-access from os import path +from unittest.mock import patch +import pytest + +from homeassistant import config_entries import homeassistant.helpers.translation as translation from homeassistant.setup import async_setup_component +@pytest.fixture +def mock_config_flows(): + """Mock the config flows.""" + flows = [] + with patch.object(config_entries, 'FLOWS', flows): + yield flows + + def test_flatten(): """Test the flatten function.""" data = { @@ -38,15 +50,15 @@ async def test_component_translation_file(hass): }) assert path.normpath(translation.component_translation_file( - 'switch.test', 'en')) == path.normpath(hass.config.path( + hass, 'switch.test', 'en')) == path.normpath(hass.config.path( 'custom_components', 'switch', '.translations', 'test.en.json')) assert path.normpath(translation.component_translation_file( - 'test_standalone', 'en')) == path.normpath(hass.config.path( + hass, 'test_standalone', 'en')) == path.normpath(hass.config.path( 'custom_components', '.translations', 'test_standalone.en.json')) assert path.normpath(translation.component_translation_file( - 'test_package', 'en')) == path.normpath(hass.config.path( + hass, 'test_package', 'en')) == path.normpath(hass.config.path( 'custom_components', 'test_package', '.translations', 'en.json')) @@ -71,7 +83,7 @@ def test_load_translations_files(hass): } -async def test_get_translations(hass): +async def test_get_translations(hass, mock_config_flows): """Test the get translations helper.""" translations = await translation.async_get_translations(hass, 'en') assert translations == {} @@ -106,3 +118,17 @@ async def test_get_translations(hass): 'component.switch.state.string1': 'Value 1', 'component.switch.state.string2': 'Value 2', } + + +async def test_get_translations_loads_config_flows(hass, mock_config_flows): + """Test the get translations helper loads config flow translations.""" + mock_config_flows.append('component1') + + with patch.object(translation, 'component_translation_file', + return_value='bla.json'), \ + patch.object(translation, 'load_translations_files', return_value={ + 'component1': {'hello': 'world'}}): + translations = await translation.async_get_translations(hass, 'en') + assert translations == { + 'component.component1.hello': 'world' + } diff --git a/tests/mock/homekit.py b/tests/mock/homekit.py deleted file mode 100644 index 2872fa59f19..00000000000 --- a/tests/mock/homekit.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Basic mock functions and objects related to the HomeKit component.""" -PATH_HOMEKIT = 'homeassistant.components.homekit' - - -def get_patch_paths(name=None): - """Return paths to mock 'add_preload_service'.""" - path_acc = PATH_HOMEKIT + '.accessories.add_preload_service' - path_file = PATH_HOMEKIT + '.' + str(name) + '.add_preload_service' - return (path_acc, path_file) - - -def mock_preload_service(acc, service, chars=None, opt_chars=None): - """Mock alternative for function 'add_preload_service'.""" - service = MockService(service) - if chars: - chars = chars if isinstance(chars, list) else [chars] - for char_name in chars: - service.add_characteristic(char_name) - if opt_chars: - opt_chars = opt_chars if isinstance(opt_chars, list) else [opt_chars] - for opt_char_name in opt_chars: - service.add_characteristic(opt_char_name) - acc.add_service(service) - return service - - -class MockAccessory(): - """Define all attributes and methods for a MockAccessory.""" - - def __init__(self, name): - """Initialize a MockAccessory object.""" - self.display_name = name - self.services = [] - - def __repr__(self): - """Return a representation of a MockAccessory. Use for debugging.""" - serv_list = [serv.display_name for serv in self.services] - return "".format( - self.display_name, serv_list) - - def add_service(self, service): - """Add service to list of services.""" - self.services.append(service) - - def get_service(self, name): - """Retrieve service from service list or return new MockService.""" - for serv in self.services: - if serv.display_name == name: - return serv - serv = MockService(name) - self.add_service(serv) - return serv - - -class MockService(): - """Define all attributes and methods for a MockService.""" - - def __init__(self, name): - """Initialize a MockService object.""" - self.characteristics = [] - self.opt_characteristics = [] - self.display_name = name - - def __repr__(self): - """Return a representation of a MockService. Use for debugging.""" - char_list = [char.display_name for char in self.characteristics] - opt_char_list = [ - char.display_name for char in self.opt_characteristics] - return "".format( - self.display_name, char_list, opt_char_list) - - def add_characteristic(self, char): - """Add characteristic to char list.""" - self.characteristics.append(char) - - def add_opt_characteristic(self, char): - """Add characteristic to opt_char list.""" - self.opt_characteristics.append(char) - - def get_characteristic(self, name): - """Get char for char lists or return new MockChar.""" - for char in self.characteristics: - if char.display_name == name: - return char - for char in self.opt_characteristics: - if char.display_name == name: - return char - char = MockChar(name) - self.add_characteristic(char) - return char - - -class MockChar(): - """Define all attributes and methods for a MockChar.""" - - def __init__(self, name): - """Initialize a MockChar object.""" - self.display_name = name - self.properties = {} - self.value = None - self.type_id = None - self.setter_callback = None - - def __repr__(self): - """Return a representation of a MockChar. Use for debugging.""" - return "".format( - self.display_name, self.value) - - def set_value(self, value, should_notify=True, should_callback=True): - """Set value of char.""" - self.value = value - if self.setter_callback is not None and should_callback: - # pylint: disable=not-callable - self.setter_callback(value) - - def get_value(self): - """Get char value.""" - return self.value - - -class MockTypeLoader(): - """Define all attributes and methods for a MockTypeLoader.""" - - def __init__(self, class_type): - """Initialize a MockTypeLoader object.""" - self.class_type = class_type - - def get(self, name): - """Return a MockService or MockChar object.""" - if self.class_type == 'service': - return MockService(name) - elif self.class_type == 'char': - return MockChar(name) diff --git a/tests/mock/zwave.py b/tests/mock/zwave.py index 672cc884904..59d97ddb621 100644 --- a/tests/mock/zwave.py +++ b/tests/mock/zwave.py @@ -119,6 +119,8 @@ class MockNode(MagicMock): product_type='678', command_classes=None, can_wake_up_value=True, + manufacturer_name='Test Manufacturer', + product_name='Test Product', network=None, **kwargs): """Initialize a Z-Wave mock node.""" @@ -128,6 +130,8 @@ class MockNode(MagicMock): self.manufacturer_id = manufacturer_id self.product_id = product_id self.product_type = product_type + self.manufacturer_name = manufacturer_name + self.product_name = product_name self.can_wake_up_value = can_wake_up_value self._command_classes = command_classes or [] if network is not None: @@ -174,6 +178,7 @@ class MockValue(MagicMock): MockValue._mock_value_id += 1 value_id = MockValue._mock_value_id self.value_id = value_id + self.object_id = value_id for attr_name in kwargs: setattr(self, attr_name, kwargs[attr_name]) diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py new file mode 100644 index 00000000000..2e837b06b58 --- /dev/null +++ b/tests/scripts/test_auth.py @@ -0,0 +1,100 @@ +"""Test the auth script to manage local users.""" +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.scripts import auth as script_auth +from homeassistant.auth_providers import homeassistant as hass_auth + +MOCK_PATH = '/bla/users.json' + + +def test_list_user(capsys): + """Test we can list users.""" + data = hass_auth.Data(MOCK_PATH, None) + data.add_user('test-user', 'test-pass') + data.add_user('second-user', 'second-pass') + + script_auth.list_users(data, None) + + captured = capsys.readouterr() + + assert captured.out == '\n'.join([ + 'test-user', + 'second-user', + '', + 'Total users: 2', + '' + ]) + + +def test_add_user(capsys): + """Test we can add a user.""" + data = hass_auth.Data(MOCK_PATH, None) + + with patch.object(data, 'save') as mock_save: + script_auth.add_user( + data, Mock(username='paulus', password='test-pass')) + + assert len(mock_save.mock_calls) == 1 + + captured = capsys.readouterr() + assert captured.out == 'User created\n' + + assert len(data.users) == 1 + data.validate_login('paulus', 'test-pass') + + +def test_validate_login(capsys): + """Test we can validate a user login.""" + data = hass_auth.Data(MOCK_PATH, None) + data.add_user('test-user', 'test-pass') + + script_auth.validate_login( + data, Mock(username='test-user', password='test-pass')) + captured = capsys.readouterr() + assert captured.out == 'Auth valid\n' + + script_auth.validate_login( + data, Mock(username='test-user', password='invalid-pass')) + captured = capsys.readouterr() + assert captured.out == 'Auth invalid\n' + + script_auth.validate_login( + data, Mock(username='invalid-user', password='test-pass')) + captured = capsys.readouterr() + assert captured.out == 'Auth invalid\n' + + +def test_change_password(capsys): + """Test we can change a password.""" + data = hass_auth.Data(MOCK_PATH, None) + data.add_user('test-user', 'test-pass') + + with patch.object(data, 'save') as mock_save: + script_auth.change_password( + data, Mock(username='test-user', new_password='new-pass')) + + assert len(mock_save.mock_calls) == 1 + captured = capsys.readouterr() + assert captured.out == 'Password changed\n' + data.validate_login('test-user', 'new-pass') + with pytest.raises(hass_auth.InvalidAuth): + data.validate_login('test-user', 'test-pass') + + +def test_change_password_invalid_user(capsys): + """Test changing password of non-existing user.""" + data = hass_auth.Data(MOCK_PATH, None) + data.add_user('test-user', 'test-pass') + + with patch.object(data, 'save') as mock_save: + script_auth.change_password( + data, Mock(username='invalid-user', new_password='new-pass')) + + assert len(mock_save.mock_calls) == 0 + captured = capsys.readouterr() + assert captured.out == 'User not found\n' + data.validate_login('test-user', 'test-pass') + with pytest.raises(hass_auth.InvalidAuth): + data.validate_login('invalid-user', 'new-pass') diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 677ed8de110..8dfc5db90e0 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -7,7 +7,6 @@ from unittest.mock import patch import homeassistant.scripts.check_config as check_config from homeassistant.config import YAML_CONFIG_FILE -from homeassistant.loader import set_component from tests.common import patch_yaml_files, get_test_config_dir _LOGGER = logging.getLogger(__name__) @@ -41,9 +40,7 @@ class TestCheckConfig(unittest.TestCase): # this ensures we have one. try: asyncio.get_event_loop() - except (RuntimeError, AssertionError): - # Py35: RuntimeError - # Py34: AssertionError + except RuntimeError: asyncio.set_event_loop(asyncio.new_event_loop()) # Will allow seeing full diff @@ -108,7 +105,6 @@ class TestCheckConfig(unittest.TestCase): def test_component_platform_not_found(self, isfile_patch): """Test errors if component or platform not found.""" # Make sure they don't exist - set_component('beer', None) files = { YAML_CONFIG_FILE: BASE_CONFIG + 'beer:', } @@ -121,7 +117,6 @@ class TestCheckConfig(unittest.TestCase): assert res['secrets'] == {} assert len(res['yaml_files']) == 1 - set_component('light.beer', None) files = { YAML_CONFIG_FILE: BASE_CONFIG + 'light:\n platform: beer', } diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 00000000000..4bbf218fd23 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,159 @@ +"""Tests for the Home Assistant auth module.""" +from unittest.mock import Mock + +import pytest + +from homeassistant import auth, data_entry_flow +from tests.common import MockUser, ensure_auth_manager_loaded + + +@pytest.fixture +def mock_hass(): + """Hass mock with minimum amount of data set to make it work with auth.""" + hass = Mock() + hass.config.skip_pip = True + return hass + + +async def test_auth_manager_from_config_validates_config_and_id(mock_hass): + """Test get auth providers.""" + manager = await auth.auth_manager_from_config(mock_hass, [{ + 'name': 'Test Name', + 'type': 'insecure_example', + 'users': [], + }, { + 'name': 'Invalid config because no users', + 'type': 'insecure_example', + 'id': 'invalid_config', + }, { + 'name': 'Test Name 2', + 'type': 'insecure_example', + 'id': 'another', + 'users': [], + }, { + 'name': 'Wrong because duplicate ID', + 'type': 'insecure_example', + 'id': 'another', + 'users': [], + }]) + + providers = [{ + 'name': provider.name, + 'id': provider.id, + 'type': provider.type, + } for provider in manager.async_auth_providers] + assert providers == [{ + 'name': 'Test Name', + 'type': 'insecure_example', + 'id': None, + }, { + 'name': 'Test Name 2', + 'type': 'insecure_example', + 'id': 'another', + }] + + +async def test_create_new_user(mock_hass): + """Test creating new user.""" + manager = await auth.auth_manager_from_config(mock_hass, [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name' + }] + }]) + + step = await manager.login_flow.async_init(('insecure_example', None)) + assert step['type'] == data_entry_flow.RESULT_TYPE_FORM + + step = await manager.login_flow.async_configure(step['flow_id'], { + 'username': 'test-user', + 'password': 'test-pass', + }) + assert step['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + credentials = step['result'] + user = await manager.async_get_or_create_user(credentials) + assert user is not None + assert user.is_owner is True + assert user.name == 'Test Name' + + +async def test_login_as_existing_user(mock_hass): + """Test login as existing user.""" + manager = await auth.auth_manager_from_config(mock_hass, [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name' + }] + }]) + ensure_auth_manager_loaded(manager) + + # Add fake user with credentials for example auth provider. + user = MockUser( + id='mock-user', + is_owner=False, + is_active=False, + name='Paulus', + ).add_to_auth_manager(manager) + user.credentials.append(auth.Credentials( + id='mock-id', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'test-user'}, + is_new=False, + )) + + step = await manager.login_flow.async_init(('insecure_example', None)) + assert step['type'] == data_entry_flow.RESULT_TYPE_FORM + + step = await manager.login_flow.async_configure(step['flow_id'], { + 'username': 'test-user', + 'password': 'test-pass', + }) + assert step['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + credentials = step['result'] + + user = await manager.async_get_or_create_user(credentials) + assert user is not None + assert user.id == 'mock-user' + assert user.is_owner is False + assert user.is_active is False + assert user.name == 'Paulus' + + +async def test_linking_user_to_two_auth_providers(mock_hass): + """Test linking user to two auth providers.""" + manager = await auth.auth_manager_from_config(mock_hass, [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + }] + }, { + 'type': 'insecure_example', + 'id': 'another-provider', + 'users': [{ + 'username': 'another-user', + 'password': 'another-password', + }] + }]) + + step = await manager.login_flow.async_init(('insecure_example', None)) + step = await manager.login_flow.async_configure(step['flow_id'], { + 'username': 'test-user', + 'password': 'test-pass', + }) + user = await manager.async_get_or_create_user(step['result']) + assert user is not None + + step = await manager.login_flow.async_init(('insecure_example', + 'another-provider')) + step = await manager.login_flow.async_configure(step['flow_id'], { + 'username': 'another-user', + 'password': 'another-password', + }) + await manager.async_link_user(user, step['result']) + assert len(user.credentials) == 2 diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index c109ae30aad..3e4d4739779 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -40,9 +40,9 @@ def test_from_config_file(hass): assert components == hass.config.components -@asyncio.coroutine @patch('homeassistant.bootstrap.async_enable_logging', Mock()) @patch('homeassistant.bootstrap.async_register_signal_handling', Mock()) +@asyncio.coroutine def test_home_assistant_core_config_validation(hass): """Test if we pass in wrong information for HA conf.""" # Extensive HA conf validation testing is done diff --git a/tests/test_config.py b/tests/test_config.py index ab6b860ea8f..d22d6b2acfd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,6 +12,7 @@ from voluptuous import MultipleInvalid from homeassistant.core import DOMAIN, HomeAssistantError, Config import homeassistant.config as config_util from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ASSUMED_STATE, CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIT_SYSTEM, CONF_NAME, CONF_TIME_ZONE, CONF_ELEVATION, CONF_CUSTOMIZE, __version__, CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT) @@ -27,9 +28,9 @@ from homeassistant.components.config.script import ( CONFIG_PATH as SCRIPTS_CONFIG_PATH) from homeassistant.components.config.customize import ( CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) +import homeassistant.scripts.check_config as check_config -from tests.common import ( - get_test_config_dir, get_test_home_assistant, mock_coro) +from tests.common import get_test_config_dir, get_test_home_assistant CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) @@ -235,6 +236,29 @@ class TestConfig(unittest.TestCase): }, }) + def test_customize_dict_schema(self): + """Test basic customize config validation.""" + values = ( + {ATTR_FRIENDLY_NAME: None}, + {ATTR_HIDDEN: '2'}, + {ATTR_ASSUMED_STATE: '2'}, + ) + + for val in values: + print(val) + with pytest.raises(MultipleInvalid): + config_util.CUSTOMIZE_DICT_SCHEMA(val) + + assert config_util.CUSTOMIZE_DICT_SCHEMA({ + ATTR_FRIENDLY_NAME: 2, + ATTR_HIDDEN: '1', + ATTR_ASSUMED_STATE: '0', + }) == { + ATTR_FRIENDLY_NAME: '2', + ATTR_HIDDEN: True, + ATTR_ASSUMED_STATE: False + } + def test_customize_glob_is_ordered(self): """Test that customize_glob preserves order.""" conf = config_util.CORE_CONFIG_SCHEMA( @@ -514,35 +538,25 @@ class TestConfig(unittest.TestCase): assert len(self.hass.config.whitelist_external_dirs) == 1 assert "/test/config/www" in self.hass.config.whitelist_external_dirs - @mock.patch('asyncio.create_subprocess_exec') - def test_check_ha_config_file_correct(self, mock_create): + @mock.patch('homeassistant.scripts.check_config.check_ha_config_file') + def test_check_ha_config_file_correct(self, mock_check): """Check that restart propagates to stop.""" - process_mock = mock.MagicMock() - attrs = { - 'communicate.return_value': mock_coro((b'output', None)), - 'wait.return_value': mock_coro(0)} - process_mock.configure_mock(**attrs) - mock_create.return_value = mock_coro(process_mock) - + mock_check.return_value = check_config.HomeAssistantConfig() assert run_coroutine_threadsafe( - config_util.async_check_ha_config_file(self.hass), self.hass.loop + config_util.async_check_ha_config_file(self.hass), + self.hass.loop ).result() is None - @mock.patch('asyncio.create_subprocess_exec') - def test_check_ha_config_file_wrong(self, mock_create): + @mock.patch('homeassistant.scripts.check_config.check_ha_config_file') + def test_check_ha_config_file_wrong(self, mock_check): """Check that restart with a bad config doesn't propagate to stop.""" - process_mock = mock.MagicMock() - attrs = { - 'communicate.return_value': - mock_coro(('\033[34mhello'.encode('utf-8'), None)), - 'wait.return_value': mock_coro(1)} - process_mock.configure_mock(**attrs) - mock_create.return_value = mock_coro(process_mock) + mock_check.return_value = check_config.HomeAssistantConfig() + mock_check.return_value.add_error("bad") assert run_coroutine_threadsafe( config_util.async_check_ha_config_file(self.hass), self.hass.loop - ).result() == 'hello' + ).result() == 'bad' # pylint: disable=redefined-outer-name @@ -554,7 +568,7 @@ def merge_log_err(hass): yield logerr -def test_merge(merge_log_err): +def test_merge(merge_log_err, hass): """Test if we can merge packages.""" packages = { 'pack_dict': {'input_boolean': {'ib1': None}}, @@ -568,7 +582,7 @@ def test_merge(merge_log_err): 'input_boolean': {'ib2': None}, 'light': {'platform': 'test'} } - config_util.merge_packages_config(config, packages) + config_util.merge_packages_config(hass, config, packages) assert merge_log_err.call_count == 0 assert len(config) == 5 @@ -578,7 +592,26 @@ def test_merge(merge_log_err): assert config['wake_on_lan'] is None -def test_merge_new(merge_log_err): +def test_merge_try_falsy(merge_log_err, hass): + """Ensure we dont add falsy items like empty OrderedDict() to list.""" + packages = { + 'pack_falsy_to_lst': {'automation': OrderedDict()}, + 'pack_list2': {'light': OrderedDict()}, + } + config = { + config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, + 'automation': {'do': 'something'}, + 'light': {'some': 'light'}, + } + config_util.merge_packages_config(hass, config, packages) + + assert merge_log_err.call_count == 0 + assert len(config) == 3 + assert len(config['automation']) == 1 + assert len(config['light']) == 1 + + +def test_merge_new(merge_log_err, hass): """Test adding new components to outer scope.""" packages = { 'pack_1': {'light': [{'platform': 'one'}]}, @@ -591,7 +624,7 @@ def test_merge_new(merge_log_err): config = { config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, } - config_util.merge_packages_config(config, packages) + config_util.merge_packages_config(hass, config, packages) assert merge_log_err.call_count == 0 assert 'api' in config @@ -600,7 +633,7 @@ def test_merge_new(merge_log_err): assert len(config['panel_custom']) == 1 -def test_merge_type_mismatch(merge_log_err): +def test_merge_type_mismatch(merge_log_err, hass): """Test if we have a type mismatch for packages.""" packages = { 'pack_1': {'input_boolean': [{'ib1': None}]}, @@ -613,7 +646,7 @@ def test_merge_type_mismatch(merge_log_err): 'input_select': [{'ib2': None}], 'light': [{'platform': 'two'}] } - config_util.merge_packages_config(config, packages) + config_util.merge_packages_config(hass, config, packages) assert merge_log_err.call_count == 2 assert len(config) == 4 @@ -621,21 +654,81 @@ def test_merge_type_mismatch(merge_log_err): assert len(config['light']) == 2 -def test_merge_once_only(merge_log_err): - """Test if we have a merge for a comp that may occur only once.""" - packages = { - 'pack_2': { - 'mqtt': {}, - 'api': {}, # No config schema - }, - } +def test_merge_once_only_keys(merge_log_err, hass): + """Test if we have a merge for a comp that may occur only once. Keys.""" + packages = {'pack_2': {'api': { + 'key_3': 3, + }}} config = { config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, - 'mqtt': {}, 'api': {} + 'api': { + 'key_1': 1, + 'key_2': 2, + } } - config_util.merge_packages_config(config, packages) + config_util.merge_packages_config(hass, config, packages) + assert config['api'] == {'key_1': 1, 'key_2': 2, 'key_3': 3, } + + # Duplicate keys error + packages = {'pack_2': {'api': { + 'key': 2, + }}} + config = { + config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, + 'api': {'key': 1, } + } + config_util.merge_packages_config(hass, config, packages) assert merge_log_err.call_count == 1 - assert len(config) == 3 + + +def test_merge_once_only_lists(hass): + """Test if we have a merge for a comp that may occur only once. Lists.""" + packages = {'pack_2': {'api': { + 'list_1': ['item_2', 'item_3'], + 'list_2': ['item_1'], + 'list_3': [], + }}} + config = { + config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, + 'api': { + 'list_1': ['item_1'], + } + } + config_util.merge_packages_config(hass, config, packages) + assert config['api'] == { + 'list_1': ['item_1', 'item_2', 'item_3'], + 'list_2': ['item_1'], + } + + +def test_merge_once_only_dictionaries(hass): + """Test if we have a merge for a comp that may occur only once. Dicts.""" + packages = {'pack_2': {'api': { + 'dict_1': { + 'key_2': 2, + 'dict_1.1': {'key_1.2': 1.2, }, + }, + 'dict_2': {'key_1': 1, }, + 'dict_3': {}, + }}} + config = { + config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, + 'api': { + 'dict_1': { + 'key_1': 1, + 'dict_1.1': {'key_1.1': 1.1, } + }, + } + } + config_util.merge_packages_config(hass, config, packages) + assert config['api'] == { + 'dict_1': { + 'key_1': 1, + 'key_2': 2, + 'dict_1.1': {'key_1.1': 1.1, 'key_1.2': 1.2, }, + }, + 'dict_2': {'key_1': 1, }, + } def test_merge_id_schema(hass): @@ -649,13 +742,13 @@ def test_merge_id_schema(hass): 'qwikswitch': 'dict', } for name, expected_type in types.items(): - module = config_util.get_component(name) + module = config_util.get_component(hass, name) typ, _ = config_util._identify_config_schema(module) assert typ == expected_type, "{} expected {}, got {}".format( name, expected_type, typ) -def test_merge_duplicate_keys(merge_log_err): +def test_merge_duplicate_keys(merge_log_err, hass): """Test if keys in dicts are duplicates.""" packages = { 'pack_1': {'input_select': {'ib1': None}}, @@ -664,7 +757,7 @@ def test_merge_duplicate_keys(merge_log_err): config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, 'input_select': {'ib1': None}, } - config_util.merge_packages_config(config, packages) + config_util.merge_packages_config(hass, config, packages) assert merge_log_err.call_count == 1 assert len(config) == 2 diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 5b1ec3b8ec0..84bd0771542 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3,9 +3,8 @@ import asyncio from unittest.mock import MagicMock, patch, mock_open import pytest -import voluptuous as vol -from homeassistant import config_entries, loader +from homeassistant import config_entries, loader, data_entry_flow from homeassistant.setup import async_setup_component from tests.common import MockModule, mock_coro, MockConfigEntry @@ -28,7 +27,7 @@ def test_call_setup_entry(hass): mock_setup_entry = MagicMock(return_value=mock_coro(True)) loader.set_component( - 'comp', + hass, 'comp', MockModule('comp', async_setup_entry=mock_setup_entry)) result = yield from async_setup_component(hass, 'comp', {}) @@ -37,12 +36,12 @@ def test_call_setup_entry(hass): @asyncio.coroutine -def test_remove_entry(manager): +def test_remove_entry(hass, manager): """Test that we can remove an entry.""" mock_unload_entry = MagicMock(return_value=mock_coro(True)) loader.set_component( - 'test', + hass, 'test', MockModule('comp', async_unload_entry=mock_unload_entry)) MockConfigEntry(domain='test', entry_id='test1').add_to_manager(manager) @@ -64,7 +63,7 @@ def test_remove_entry(manager): @asyncio.coroutine -def test_remove_entry_raises(manager): +def test_remove_entry_raises(hass, manager): """Test if a component raises while removing entry.""" @asyncio.coroutine def mock_unload_entry(hass, entry): @@ -72,7 +71,7 @@ def test_remove_entry_raises(manager): raise Exception("BROKEN") loader.set_component( - 'test', + hass, 'test', MockModule('comp', async_unload_entry=mock_unload_entry)) MockConfigEntry(domain='test', entry_id='test1').add_to_manager(manager) @@ -97,10 +96,10 @@ def test_add_entry_calls_setup_entry(hass, manager): mock_setup_entry = MagicMock(return_value=mock_coro(True)) loader.set_component( - 'comp', + hass, 'comp', MockModule('comp', async_setup_entry=mock_setup_entry)) - class TestFlow(config_entries.ConfigFlowHandler): + class TestFlow(data_entry_flow.FlowHandler): VERSION = 1 @@ -112,7 +111,7 @@ def test_add_entry_calls_setup_entry(hass, manager): 'token': 'supersecret' }) - with patch.dict(config_entries.HANDLERS, {'comp': TestFlow}): + with patch.dict(config_entries.HANDLERS, {'comp': TestFlow, 'beer': 5}): yield from manager.flow.async_init('comp') yield from hass.async_block_till_done() @@ -152,7 +151,9 @@ def test_domains_gets_uniques(manager): @asyncio.coroutine def test_saving_and_loading(hass): """Test that we're saving and loading correctly.""" - class TestFlow(config_entries.ConfigFlowHandler): + loader.set_component(hass, 'test', MockModule('test')) + + class TestFlow(data_entry_flow.FlowHandler): VERSION = 5 @asyncio.coroutine @@ -167,7 +168,7 @@ def test_saving_and_loading(hass): with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): yield from hass.config_entries.flow.async_init('test') - class Test2Flow(config_entries.ConfigFlowHandler): + class Test2Flow(data_entry_flow.FlowHandler): VERSION = 3 @asyncio.coroutine @@ -212,180 +213,94 @@ def test_saving_and_loading(hass): assert orig.source == loaded.source -####################### -# FLOW MANAGER TESTS # -####################### +async def test_forward_entry_sets_up_component(hass): + """Test we setup the component entry is forwarded to.""" + entry = MockConfigEntry(domain='original') -@asyncio.coroutine -def test_configure_reuses_handler_instance(manager): - """Test that we reuse instances.""" - class TestFlow(config_entries.ConfigFlowHandler): - handle_count = 0 + mock_original_setup_entry = MagicMock(return_value=mock_coro(True)) + loader.set_component( + hass, 'original', + MockModule('original', async_setup_entry=mock_original_setup_entry)) - @asyncio.coroutine - def async_step_init(self, user_input=None): - self.handle_count += 1 - return self.async_show_form( - errors={'base': str(self.handle_count)}, - step_id='init') + mock_forwarded_setup_entry = MagicMock(return_value=mock_coro(True)) + loader.set_component( + hass, 'forwarded', + MockModule('forwarded', async_setup_entry=mock_forwarded_setup_entry)) - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - form = yield from manager.flow.async_init('test') - assert form['errors']['base'] == '1' - form = yield from manager.flow.async_configure(form['flow_id']) - assert form['errors']['base'] == '2' - assert len(manager.flow.async_progress()) == 1 - assert len(manager.async_entries()) == 0 + await hass.config_entries.async_forward_entry_setup(entry, 'forwarded') + assert len(mock_original_setup_entry.mock_calls) == 0 + assert len(mock_forwarded_setup_entry.mock_calls) == 1 -@asyncio.coroutine -def test_configure_two_steps(manager): - """Test that we reuse instances.""" - class TestFlow(config_entries.ConfigFlowHandler): - VERSION = 1 +async def test_forward_entry_does_not_setup_entry_if_setup_fails(hass): + """Test we do not setup entry if component setup fails.""" + entry = MockConfigEntry(domain='original') - @asyncio.coroutine - def async_step_init(self, user_input=None): - if user_input is not None: - self.init_data = user_input - return self.async_step_second() - return self.async_show_form( - step_id='init', - data_schema=vol.Schema([str]) - ) + mock_setup = MagicMock(return_value=mock_coro(False)) + mock_setup_entry = MagicMock() + hass, loader.set_component(hass, 'forwarded', MockModule( + 'forwarded', + async_setup=mock_setup, + async_setup_entry=mock_setup_entry, + )) - @asyncio.coroutine - def async_step_second(self, user_input=None): + await hass.config_entries.async_forward_entry_setup(entry, 'forwarded') + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_discovery_notification(hass): + """Test that we create/dismiss a notification when source is discovery.""" + loader.set_component(hass, 'test', MockModule('test')) + await async_setup_component(hass, 'persistent_notification', {}) + + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + + async def async_step_discovery(self, user_input=None): if user_input is not None: return self.async_create_entry( - title='Test Entry', - data=self.init_data + user_input + title='Test Title', + data={ + 'token': 'abcd' + } ) return self.async_show_form( - step_id='second', - data_schema=vol.Schema([str]) + step_id='discovery', ) with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - form = yield from manager.flow.async_init('test') + result = await hass.config_entries.flow.async_init( + 'test', source=data_entry_flow.SOURCE_DISCOVERY) - with pytest.raises(vol.Invalid): - form = yield from manager.flow.async_configure( - form['flow_id'], 'INCORRECT-DATA') + await hass.async_block_till_done() + state = hass.states.get('persistent_notification.config_entry_discovery') + assert state is not None - form = yield from manager.flow.async_configure( - form['flow_id'], ['INIT-DATA']) - form = yield from manager.flow.async_configure( - form['flow_id'], ['SECOND-DATA']) - assert form['type'] == config_entries.RESULT_TYPE_CREATE_ENTRY - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 1 - entry = manager.async_entries()[0] - assert entry.domain == 'test' - assert entry.data == ['INIT-DATA', 'SECOND-DATA'] + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + await hass.async_block_till_done() + state = hass.states.get('persistent_notification.config_entry_discovery') + assert state is None -@asyncio.coroutine -def test_show_form(manager): - """Test that abort removes the flow from progress.""" - schema = vol.Schema({ - vol.Required('username'): str, - vol.Required('password'): str - }) +async def test_discovery_notification_not_created(hass): + """Test that we not create a notification when discovery is aborted.""" + loader.set_component(hass, 'test', MockModule('test')) + await async_setup_component(hass, 'persistent_notification', {}) - class TestFlow(config_entries.ConfigFlowHandler): - @asyncio.coroutine - def async_step_init(self, user_input=None): - return self.async_show_form( - step_id='init', - data_schema=schema, - errors={ - 'username': 'Should be unique.' - } - ) - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - form = yield from manager.flow.async_init('test') - assert form['type'] == 'form' - assert form['data_schema'] is schema - assert form['errors'] == { - 'username': 'Should be unique.' - } - - -@asyncio.coroutine -def test_abort_removes_instance(manager): - """Test that abort removes the flow from progress.""" - class TestFlow(config_entries.ConfigFlowHandler): - is_new = True - - @asyncio.coroutine - def async_step_init(self, user_input=None): - old = self.is_new - self.is_new = False - return self.async_abort(reason=str(old)) - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - form = yield from manager.flow.async_init('test') - assert form['reason'] == 'True' - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 0 - form = yield from manager.flow.async_init('test') - assert form['reason'] == 'True' - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 0 - - -@asyncio.coroutine -def test_create_saves_data(manager): - """Test creating a config entry.""" - class TestFlow(config_entries.ConfigFlowHandler): + class TestFlow(data_entry_flow.FlowHandler): VERSION = 5 - @asyncio.coroutine - def async_step_init(self, user_input=None): - return self.async_create_entry( - title='Test Title', - data='Test Data' - ) + async def async_step_discovery(self, user_input=None): + return self.async_abort(reason='test') with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - yield from manager.flow.async_init('test') - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 1 + await hass.config_entries.flow.async_init( + 'test', source=data_entry_flow.SOURCE_DISCOVERY) - entry = manager.async_entries()[0] - assert entry.version == 5 - assert entry.domain == 'test' - assert entry.title == 'Test Title' - assert entry.data == 'Test Data' - assert entry.source == config_entries.SOURCE_USER - - -@asyncio.coroutine -def test_discovery_init_flow(manager): - """Test a flow initialized by discovery.""" - class TestFlow(config_entries.ConfigFlowHandler): - VERSION = 5 - - @asyncio.coroutine - def async_step_discovery(self, info): - return self.async_create_entry(title=info['id'], data=info) - - data = { - 'id': 'hello', - 'token': 'secret' - } - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - yield from manager.flow.async_init( - 'test', source=config_entries.SOURCE_DISCOVERY, data=data) - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 1 - - entry = manager.async_entries()[0] - assert entry.version == 5 - assert entry.domain == 'test' - assert entry.title == 'hello' - assert entry.data == data - assert entry.source == config_entries.SOURCE_DISCOVERY + await hass.async_block_till_done() + state = hass.states.get('persistent_notification.config_entry_discovery') + assert state is None diff --git a/tests/test_core.py b/tests/test_core.py index 7a1610c0966..4abce180093 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -375,7 +375,7 @@ class TestEventBus(unittest.TestCase): self.assertEqual(1, len(runs)) def test_thread_event_listener(self): - """Test a event listener listeners.""" + """Test thread event listener.""" thread_calls = [] def thread_listener(event): @@ -387,7 +387,7 @@ class TestEventBus(unittest.TestCase): assert len(thread_calls) == 1 def test_callback_event_listener(self): - """Test a event listener listeners.""" + """Test callback event listener.""" callback_calls = [] @ha.callback @@ -400,7 +400,7 @@ class TestEventBus(unittest.TestCase): assert len(callback_calls) == 1 def test_coroutine_event_listener(self): - """Test a event listener listeners.""" + """Test coroutine event listener.""" coroutine_calls = [] @asyncio.coroutine @@ -809,7 +809,8 @@ class TestConfig(unittest.TestCase): valid = [ test_file, - tmp_dir + tmp_dir, + os.path.join(tmp_dir, 'notfound321') ] for path in valid: assert self.config.is_allowed_path(path) diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py new file mode 100644 index 00000000000..894fd4d7194 --- /dev/null +++ b/tests/test_data_entry_flow.py @@ -0,0 +1,193 @@ +"""Test the flow classes.""" +import pytest +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.util.decorator import Registry + + +@pytest.fixture +def manager(): + """Return a flow manager.""" + handlers = Registry() + entries = [] + + async def async_create_flow(handler_name, *, source, data): + handler = handlers.get(handler_name) + + if handler is None: + raise data_entry_flow.UnknownHandler + + return handler() + + async def async_add_entry(result): + if (result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY): + entries.append(result) + + manager = data_entry_flow.FlowManager( + None, async_create_flow, async_add_entry) + manager.mock_created_entries = entries + manager.mock_reg_handler = handlers.register + return manager + + +async def test_configure_reuses_handler_instance(manager): + """Test that we reuse instances.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + handle_count = 0 + + async def async_step_init(self, user_input=None): + self.handle_count += 1 + return self.async_show_form( + errors={'base': str(self.handle_count)}, + step_id='init') + + form = await manager.async_init('test') + assert form['errors']['base'] == '1' + form = await manager.async_configure(form['flow_id']) + assert form['errors']['base'] == '2' + assert len(manager.async_progress()) == 1 + assert len(manager.mock_created_entries) == 0 + + +async def test_configure_two_steps(manager): + """Test that we reuse instances.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 1 + + async def async_step_init(self, user_input=None): + if user_input is not None: + self.init_data = user_input + return await self.async_step_second() + return self.async_show_form( + step_id='init', + data_schema=vol.Schema([str]) + ) + + async def async_step_second(self, user_input=None): + if user_input is not None: + return self.async_create_entry( + title='Test Entry', + data=self.init_data + user_input + ) + return self.async_show_form( + step_id='second', + data_schema=vol.Schema([str]) + ) + + form = await manager.async_init('test') + + with pytest.raises(vol.Invalid): + form = await manager.async_configure( + form['flow_id'], 'INCORRECT-DATA') + + form = await manager.async_configure( + form['flow_id'], ['INIT-DATA']) + form = await manager.async_configure( + form['flow_id'], ['SECOND-DATA']) + assert form['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 + result = manager.mock_created_entries[0] + assert result['handler'] == 'test' + assert result['data'] == ['INIT-DATA', 'SECOND-DATA'] + + +async def test_show_form(manager): + """Test that abort removes the flow from progress.""" + schema = vol.Schema({ + vol.Required('username'): str, + vol.Required('password'): str + }) + + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_show_form( + step_id='init', + data_schema=schema, + errors={ + 'username': 'Should be unique.' + } + ) + + form = await manager.async_init('test') + assert form['type'] == 'form' + assert form['data_schema'] is schema + assert form['errors'] == { + 'username': 'Should be unique.' + } + + +async def test_abort_removes_instance(manager): + """Test that abort removes the flow from progress.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + is_new = True + + async def async_step_init(self, user_input=None): + old = self.is_new + self.is_new = False + return self.async_abort(reason=str(old)) + + form = await manager.async_init('test') + assert form['reason'] == 'True' + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + form = await manager.async_init('test') + assert form['reason'] == 'True' + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + + +async def test_create_saves_data(manager): + """Test creating a config entry.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + + async def async_step_init(self, user_input=None): + return self.async_create_entry( + title='Test Title', + data='Test Data' + ) + + await manager.async_init('test') + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 + + entry = manager.mock_created_entries[0] + assert entry['version'] == 5 + assert entry['handler'] == 'test' + assert entry['title'] == 'Test Title' + assert entry['data'] == 'Test Data' + assert entry['source'] == data_entry_flow.SOURCE_USER + + +async def test_discovery_init_flow(manager): + """Test a flow initialized by discovery.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + + async def async_step_discovery(self, info): + return self.async_create_entry(title=info['id'], data=info) + + data = { + 'id': 'hello', + 'token': 'secret' + } + + await manager.async_init( + 'test', source=data_entry_flow.SOURCE_DISCOVERY, data=data) + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 + + entry = manager.mock_created_entries[0] + assert entry['version'] == 5 + assert entry['handler'] == 'test' + assert entry['title'] == 'hello' + assert entry['data'] == data + assert entry['source'] == data_entry_flow.SOURCE_DISCOVERY diff --git a/tests/test_loader.py b/tests/test_loader.py index 7fc33df57bb..c97e94a7ce1 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -27,37 +27,39 @@ class TestLoader(unittest.TestCase): def test_set_component(self): """Test if set_component works.""" - loader.set_component('switch.test_set', http) + comp = object() + loader.set_component(self.hass, 'switch.test_set', comp) - self.assertEqual(http, loader.get_component('switch.test_set')) + assert loader.get_component(self.hass, 'switch.test_set') is comp def test_get_component(self): """Test if get_component works.""" - self.assertEqual(http, loader.get_component('http')) - - self.assertIsNotNone(loader.get_component('switch.test')) + self.assertEqual(http, loader.get_component(self.hass, 'http')) + self.assertIsNotNone(loader.get_component(self.hass, 'light.hue')) def test_load_order_component(self): """Test if we can get the proper load order of components.""" - loader.set_component('mod1', MockModule('mod1')) - loader.set_component('mod2', MockModule('mod2', ['mod1'])) - loader.set_component('mod3', MockModule('mod3', ['mod2'])) + loader.set_component(self.hass, 'mod1', MockModule('mod1')) + loader.set_component(self.hass, 'mod2', MockModule('mod2', ['mod1'])) + loader.set_component(self.hass, 'mod3', MockModule('mod3', ['mod2'])) self.assertEqual( - ['mod1', 'mod2', 'mod3'], loader.load_order_component('mod3')) + ['mod1', 'mod2', 'mod3'], + loader.load_order_component(self.hass, 'mod3')) # Create circular dependency - loader.set_component('mod1', MockModule('mod1', ['mod3'])) + loader.set_component(self.hass, 'mod1', MockModule('mod1', ['mod3'])) - self.assertEqual([], loader.load_order_component('mod3')) + self.assertEqual([], loader.load_order_component(self.hass, 'mod3')) # Depend on non-existing component - loader.set_component('mod1', MockModule('mod1', ['nonexisting'])) + loader.set_component(self.hass, 'mod1', + MockModule('mod1', ['nonexisting'])) - self.assertEqual([], loader.load_order_component('mod1')) + self.assertEqual([], loader.load_order_component(self.hass, 'mod1')) # Try to get load order for non-existing component - self.assertEqual([], loader.load_order_component('mod1')) + self.assertEqual([], loader.load_order_component(self.hass, 'mod1')) def test_component_loader(hass): @@ -103,3 +105,22 @@ def test_helpers_wrapper(hass): yield from hass.async_block_till_done() assert result == ['hello'] + + +async def test_custom_component_name(hass): + """Test the name attribte of custom components.""" + comp = loader.get_component(hass, 'test_standalone') + assert comp.__name__ == 'custom_components.test_standalone' + assert comp.__package__ == 'custom_components' + + comp = loader.get_component(hass, 'test_package') + assert comp.__name__ == 'custom_components.test_package' + assert comp.__package__ == 'custom_components.test_package' + + comp = loader.get_component(hass, 'light.test') + assert comp.__name__ == 'custom_components.light.test' + assert comp.__package__ == 'custom_components.light' + + # Test custom components is mounted + from custom_components.test_package import TEST + assert TEST == 5 diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 5f09e0bd83e..8ae0f6c11de 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -35,7 +35,8 @@ class TestRequirements: mock_dirname.return_value = 'ha_package_path' self.hass.config.skip_pip = False loader.set_component( - 'comp', MockModule('comp', requirements=['package==0.0.1'])) + self.hass, 'comp', + MockModule('comp', requirements=['package==0.0.1'])) assert setup.setup_component(self.hass, 'comp') assert 'comp' in self.hass.config.components assert mock_install.call_args == mock.call( @@ -53,7 +54,8 @@ class TestRequirements: mock_dirname.return_value = 'ha_package_path' self.hass.config.skip_pip = False loader.set_component( - 'comp', MockModule('comp', requirements=['package==0.0.1'])) + self.hass, 'comp', + MockModule('comp', requirements=['package==0.0.1'])) assert setup.setup_component(self.hass, 'comp') assert 'comp' in self.hass.config.components assert mock_install.call_args == mock.call( diff --git a/tests/test_setup.py b/tests/test_setup.py index 6a94310793c..6f0c282e016 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -49,6 +49,7 @@ class TestSetup: } }, required=True) loader.set_component( + self.hass, 'comp_conf', MockModule('comp_conf', config_schema=config_schema)) with assert_setup_component(0): @@ -93,10 +94,12 @@ class TestSetup: 'hello': str, }) loader.set_component( + self.hass, 'platform_conf', MockModule('platform_conf', platform_schema=platform_schema)) loader.set_component( + self.hass, 'platform_conf.whatever', MockPlatform('whatever')) with assert_setup_component(0): @@ -179,7 +182,8 @@ class TestSetup: """Test we do not setup a component twice.""" mock_setup = mock.MagicMock(return_value=True) - loader.set_component('comp', MockModule('comp', setup=mock_setup)) + loader.set_component( + self.hass, 'comp', MockModule('comp', setup=mock_setup)) assert setup.setup_component(self.hass, 'comp') assert mock_setup.called @@ -195,6 +199,7 @@ class TestSetup: """Component setup should fail if requirement can't install.""" self.hass.config.skip_pip = False loader.set_component( + self.hass, 'comp', MockModule('comp', requirements=['package==0.0.1'])) assert not setup.setup_component(self.hass, 'comp') @@ -210,6 +215,7 @@ class TestSetup: result.append(1) loader.set_component( + self.hass, 'comp', MockModule('comp', async_setup=async_setup)) def setup_component(): @@ -227,20 +233,23 @@ class TestSetup: def test_component_not_setup_missing_dependencies(self): """Test we do not setup a component if not all dependencies loaded.""" deps = ['non_existing'] - loader.set_component('comp', MockModule('comp', dependencies=deps)) + loader.set_component( + self.hass, 'comp', MockModule('comp', dependencies=deps)) assert not setup.setup_component(self.hass, 'comp', {}) assert 'comp' not in self.hass.config.components self.hass.data.pop(setup.DATA_SETUP) - loader.set_component('non_existing', MockModule('non_existing')) + loader.set_component( + self.hass, 'non_existing', MockModule('non_existing')) assert setup.setup_component(self.hass, 'comp', {}) def test_component_failing_setup(self): """Test component that fails setup.""" loader.set_component( - 'comp', MockModule('comp', setup=lambda hass, config: False)) + self.hass, 'comp', + MockModule('comp', setup=lambda hass, config: False)) assert not setup.setup_component(self.hass, 'comp', {}) assert 'comp' not in self.hass.config.components @@ -251,7 +260,8 @@ class TestSetup: """Setup that raises exception.""" raise Exception('fail!') - loader.set_component('comp', MockModule('comp', setup=exception_setup)) + loader.set_component( + self.hass, 'comp', MockModule('comp', setup=exception_setup)) assert not setup.setup_component(self.hass, 'comp', {}) assert 'comp' not in self.hass.config.components @@ -264,11 +274,12 @@ class TestSetup: return True raise Exception('Config not passed in: {}'.format(config)) - loader.set_component('comp_a', - MockModule('comp_a', setup=config_check_setup)) + loader.set_component( + self.hass, 'comp_a', + MockModule('comp_a', setup=config_check_setup)) - loader.set_component('switch.platform_a', MockPlatform('comp_b', - ['comp_a'])) + loader.set_component( + self.hass, 'switch.platform_a', MockPlatform('comp_b', ['comp_a'])) setup.setup_component(self.hass, 'switch', { 'comp_a': { @@ -289,6 +300,7 @@ class TestSetup: mock_setup = mock.MagicMock(spec_set=True) loader.set_component( + self.hass, 'switch.platform_a', MockPlatform(platform_schema=platform_schema, setup_platform=mock_setup)) @@ -330,29 +342,34 @@ class TestSetup: def test_disable_component_if_invalid_return(self): """Test disabling component if invalid return.""" loader.set_component( + self.hass, 'disabled_component', MockModule('disabled_component', setup=lambda hass, config: None)) assert not setup.setup_component(self.hass, 'disabled_component') - assert loader.get_component('disabled_component') is None + assert loader.get_component(self.hass, 'disabled_component') is None assert 'disabled_component' not in self.hass.config.components self.hass.data.pop(setup.DATA_SETUP) loader.set_component( + self.hass, 'disabled_component', MockModule('disabled_component', setup=lambda hass, config: False)) assert not setup.setup_component(self.hass, 'disabled_component') - assert loader.get_component('disabled_component') is not None + assert loader.get_component( + self.hass, 'disabled_component') is not None assert 'disabled_component' not in self.hass.config.components self.hass.data.pop(setup.DATA_SETUP) loader.set_component( + self.hass, 'disabled_component', MockModule('disabled_component', setup=lambda hass, config: True)) assert setup.setup_component(self.hass, 'disabled_component') - assert loader.get_component('disabled_component') is not None + assert loader.get_component( + self.hass, 'disabled_component') is not None assert 'disabled_component' in self.hass.config.components def test_all_work_done_before_start(self): @@ -373,14 +390,17 @@ class TestSetup: return True loader.set_component( + self.hass, 'test_component1', MockModule('test_component1', setup=component1_setup)) loader.set_component( + self.hass, 'test_component2', MockModule('test_component2', setup=component_track_setup)) loader.set_component( + self.hass, 'test_component3', MockModule('test_component3', setup=component_track_setup)) @@ -409,7 +429,8 @@ def test_component_cannot_depend_config(hass): @asyncio.coroutine def test_component_warn_slow_setup(hass): """Warn we log when a component setup takes a long time.""" - loader.set_component('test_component1', MockModule('test_component1')) + loader.set_component( + hass, 'test_component1', MockModule('test_component1')) with mock.patch.object(hass.loop, 'call_later', mock.MagicMock()) \ as mock_call: result = yield from setup.async_setup_component( @@ -430,7 +451,7 @@ def test_component_warn_slow_setup(hass): def test_platform_no_warn_slow(hass): """Do not warn for long entity setup time.""" loader.set_component( - 'test_component1', + hass, 'test_component1', MockModule('test_component1', platform_schema=PLATFORM_SCHEMA)) with mock.patch.object(hass.loop, 'call_later', mock.MagicMock()) \ as mock_call: diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index d661ffba477..e67d5de50d1 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -82,7 +82,8 @@ class AiohttpClientMocker: def create_session(self, loop): """Create a ClientSession that is bound to this mocker.""" session = ClientSession(loop=loop) - session._request = self.match_request + # Setting directly on `session` will raise deprecation warning + object.__setattr__(session, '_request', self.match_request) return session async def match_request(self, method, url, *, data=None, auth=None, diff --git a/tests/testing_config/.remember_the_milk.conf b/tests/testing_config/.remember_the_milk.conf new file mode 100644 index 00000000000..272ac0903bd --- /dev/null +++ b/tests/testing_config/.remember_the_milk.conf @@ -0,0 +1 @@ +{"myprofile": {"id_map": {}}} \ No newline at end of file diff --git a/tests/testing_config/custom_components/image_processing/test.py b/tests/testing_config/custom_components/image_processing/test.py index 29d362699f5..b50050ed68e 100644 --- a/tests/testing_config/custom_components/image_processing/test.py +++ b/tests/testing_config/custom_components/image_processing/test.py @@ -3,9 +3,11 @@ from homeassistant.components.image_processing import ImageProcessingEntity -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices_callback, + discovery_info=None): """Set up the test image_processing platform.""" - add_devices([TestImageProcessing('camera.demo_camera', "Test")]) + async_add_devices_callback([ + TestImageProcessing('camera.demo_camera', "Test")]) class TestImageProcessing(ImageProcessingEntity): diff --git a/tests/testing_config/custom_components/light/test.py b/tests/testing_config/custom_components/light/test.py index fafe88eecbe..fbf79f9e770 100644 --- a/tests/testing_config/custom_components/light/test.py +++ b/tests/testing_config/custom_components/light/test.py @@ -1,5 +1,5 @@ """ -Provide a mock switch platform. +Provide a mock light platform. Call init before using it in your tests to ensure clean test data. """ @@ -21,6 +21,7 @@ def init(empty=False): ] -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices_callback, + discovery_info=None): """Return mock devices.""" - add_devices_callback(DEVICES) + async_add_devices_callback(DEVICES) diff --git a/tests/testing_config/custom_components/switch/test.py b/tests/testing_config/custom_components/switch/test.py index 2819f2f2951..79126b7b52a 100644 --- a/tests/testing_config/custom_components/switch/test.py +++ b/tests/testing_config/custom_components/switch/test.py @@ -21,6 +21,7 @@ def init(empty=False): ] -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices_callback, + discovery_info=None): """Find and return test switches.""" - add_devices_callback(DEVICES) + async_add_devices_callback(DEVICES) diff --git a/tests/testing_config/custom_components/test_package/__init__.py b/tests/testing_config/custom_components/test_package/__init__.py index 528f056948b..85e78a7f9d6 100644 --- a/tests/testing_config/custom_components/test_package/__init__.py +++ b/tests/testing_config/custom_components/test_package/__init__.py @@ -1,7 +1,10 @@ """Provide a mock package component.""" +from .const import TEST # noqa + + DOMAIN = 'test_package' -def setup(hass, config): +async def async_setup(hass, config): """Mock a successful setup.""" return True diff --git a/tests/testing_config/custom_components/test_package/const.py b/tests/testing_config/custom_components/test_package/const.py new file mode 100644 index 00000000000..7e13e04cb47 --- /dev/null +++ b/tests/testing_config/custom_components/test_package/const.py @@ -0,0 +1,2 @@ +"""Constants for test_package custom component.""" +TEST = 5 diff --git a/tests/testing_config/custom_components/test_standalone.py b/tests/testing_config/custom_components/test_standalone.py index f0d4ba7982b..de3a360a4da 100644 --- a/tests/testing_config/custom_components/test_standalone.py +++ b/tests/testing_config/custom_components/test_standalone.py @@ -2,6 +2,6 @@ DOMAIN = 'test_standalone' -def setup(hass, config): +async def async_setup(hass, config): """Mock a successful setup.""" return True diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 86d303c23b7..74ba72cd3d1 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -10,26 +10,52 @@ class TestColorUtil(unittest.TestCase): """Test color util methods.""" # pylint: disable=invalid-name - def test_color_RGB_to_xy(self): - """Test color_RGB_to_xy.""" - self.assertEqual((0, 0, 0), color_util.color_RGB_to_xy(0, 0, 0)) - self.assertEqual((0.32, 0.336, 255), - color_util.color_RGB_to_xy(255, 255, 255)) + def test_color_RGB_to_xy_brightness(self): + """Test color_RGB_to_xy_brightness.""" + self.assertEqual((0, 0, 0), + color_util.color_RGB_to_xy_brightness(0, 0, 0)) + self.assertEqual((0.323, 0.329, 255), + color_util.color_RGB_to_xy_brightness(255, 255, 255)) self.assertEqual((0.136, 0.04, 12), - color_util.color_RGB_to_xy(0, 0, 255)) + color_util.color_RGB_to_xy_brightness(0, 0, 255)) self.assertEqual((0.172, 0.747, 170), + color_util.color_RGB_to_xy_brightness(0, 255, 0)) + + self.assertEqual((0.701, 0.299, 72), + color_util.color_RGB_to_xy_brightness(255, 0, 0)) + + self.assertEqual((0.701, 0.299, 16), + color_util.color_RGB_to_xy_brightness(128, 0, 0)) + + def test_color_RGB_to_xy(self): + """Test color_RGB_to_xy.""" + self.assertEqual((0, 0), + color_util.color_RGB_to_xy(0, 0, 0)) + self.assertEqual((0.323, 0.329), + color_util.color_RGB_to_xy(255, 255, 255)) + + self.assertEqual((0.136, 0.04), + color_util.color_RGB_to_xy(0, 0, 255)) + + self.assertEqual((0.172, 0.747), color_util.color_RGB_to_xy(0, 255, 0)) - self.assertEqual((0.679, 0.321, 80), + self.assertEqual((0.701, 0.299), color_util.color_RGB_to_xy(255, 0, 0)) + self.assertEqual((0.701, 0.299), + color_util.color_RGB_to_xy(128, 0, 0)) + def test_color_xy_brightness_to_RGB(self): - """Test color_RGB_to_xy.""" + """Test color_xy_brightness_to_RGB.""" self.assertEqual((0, 0, 0), color_util.color_xy_brightness_to_RGB(1, 1, 0)) + self.assertEqual((194, 186, 169), + color_util.color_xy_brightness_to_RGB(.35, .35, 128)) + self.assertEqual((255, 243, 222), color_util.color_xy_brightness_to_RGB(.35, .35, 255)) @@ -42,6 +68,20 @@ class TestColorUtil(unittest.TestCase): self.assertEqual((0, 63, 255), color_util.color_xy_brightness_to_RGB(0, 0, 255)) + def test_color_xy_to_RGB(self): + """Test color_xy_to_RGB.""" + self.assertEqual((255, 243, 222), + color_util.color_xy_to_RGB(.35, .35)) + + self.assertEqual((255, 0, 60), + color_util.color_xy_to_RGB(1, 0)) + + self.assertEqual((0, 255, 0), + color_util.color_xy_to_RGB(0, 1)) + + self.assertEqual((0, 63, 255), + color_util.color_xy_to_RGB(0, 0)) + def test_color_RGB_to_hsv(self): """Test color_RGB_to_hsv.""" self.assertEqual((0, 0, 0), @@ -110,6 +150,23 @@ class TestColorUtil(unittest.TestCase): self.assertEqual((225.176, 100), color_util.color_xy_to_hs(0, 0)) + def test_color_hs_to_xy(self): + """Test color_hs_to_xy.""" + self.assertEqual((0.151, 0.343), + color_util.color_hs_to_xy(180, 100)) + + self.assertEqual((0.356, 0.321), + color_util.color_hs_to_xy(350, 12.5)) + + self.assertEqual((0.229, 0.474), + color_util.color_hs_to_xy(140, 50)) + + self.assertEqual((0.474, 0.317), + color_util.color_hs_to_xy(0, 40)) + + self.assertEqual((0.323, 0.329), + color_util.color_hs_to_xy(360, 0)) + def test_rgb_hex_to_rgb_list(self): """Test rgb_hex_to_rgb_list.""" self.assertEqual([255, 255, 255], diff --git a/tests/util/test_init.py b/tests/util/test_init.py index 5493843c246..60b0e68ca59 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -291,3 +291,11 @@ async def test_throttle_async(): assert (await test_method()) is True assert (await test_method()) is None + + @util.Throttle(timedelta(seconds=2), timedelta(seconds=0.1)) + async def test_method2(): + """Only first call should return a value.""" + return True + + assert (await test_method2()) is True + assert (await test_method2()) is None diff --git a/tox.ini b/tox.ini index 86acefe9b3f..8b034346475 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py35, py36, lint, requirements, typing +envlist = py35, py36, lint, pylint, typing skip_missing_interpreters = True [testenv] @@ -12,7 +12,7 @@ setenv = whitelist_externals = /usr/bin/env install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} commands = - py.test --timeout=9 --duration=10 --cov --cov-report= {posargs} + pytest --timeout=9 --duration=10 --cov --cov-report= {posargs} deps = -r{toxinidir}/requirements_test_all.txt -c{toxinidir}/homeassistant/package_constraints.txt @@ -38,7 +38,8 @@ commands = [testenv:typing] basepython = {env:PYTHON3_PATH:python3} +whitelist_externals=/bin/bash deps = -r{toxinidir}/requirements_test.txt commands = - mypy --ignore-missing-imports --follow-imports=skip homeassistant + /bin/bash -c 'mypy --ignore-missing-imports --follow-imports=silent homeassistant/*.py' diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 06676140702..d0599c2e74c 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -13,6 +13,7 @@ LABEL maintainer="Paulus Schoutsen " #ENV INSTALL_PHANTOMJS no #ENV INSTALL_COAP no #ENV INSTALL_SSOCR no +#ENV INSTALL_IPERF3 no VOLUME /config diff --git a/virtualization/Docker/scripts/ffmpeg b/virtualization/Docker/scripts/ffmpeg deleted file mode 100755 index 81b9ce694f9..00000000000 --- a/virtualization/Docker/scripts/ffmpeg +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -# Sets up ffmpeg. - -# Stop on errors -set -e - -PACKAGES=( - ffmpeg -) - -# Add jessie-backports -echo "Adding jessie-backports" -echo "deb http://deb.debian.org/debian jessie-backports main" >> /etc/apt/sources.list -apt-get update - -apt-get install -y --no-install-recommends -t jessie-backports ${PACKAGES[@]} \ No newline at end of file diff --git a/virtualization/Docker/setup_docker_prereqs b/virtualization/Docker/setup_docker_prereqs index bd70af28dce..0cb49fde54e 100755 --- a/virtualization/Docker/setup_docker_prereqs +++ b/virtualization/Docker/setup_docker_prereqs @@ -6,7 +6,6 @@ set -e INSTALL_TELLSTICK="${INSTALL_TELLSTICK:-yes}" INSTALL_OPENALPR="${INSTALL_OPENALPR:-yes}" -INSTALL_FFMPEG="${INSTALL_FFMPEG:-yes}" INSTALL_LIBCEC="${INSTALL_LIBCEC:-yes}" INSTALL_PHANTOMJS="${INSTALL_PHANTOMJS:-yes}" INSTALL_SSOCR="${INSTALL_SSOCR:-yes}" @@ -22,9 +21,15 @@ PACKAGES=( # homeassistant.components.device_tracker.bluetooth_tracker bluetooth libglib2.0-dev libbluetooth-dev # homeassistant.components.device_tracker.owntracks - libsodium13 + libsodium18 # homeassistant.components.zwave libudev-dev + # homeassistant.components.homekit_controller + libmpc-dev libmpfr-dev libgmp-dev + # homeassistant.components.ffmpeg + ffmpeg + # homeassistant.components.sensor.iperf3 + iperf3 ) # Required debian packages for building dependencies @@ -38,6 +43,10 @@ PACKAGES_DEV=( apt-get update apt-get install -y --no-install-recommends ${PACKAGES[@]} ${PACKAGES_DEV[@]} +# This is a list of scripts that install additional dependencies. If you only +# need to install a package from the official debian repository, just add it +# to the list above. Only create a script if you need compiling, manually +# downloading or a 3th party repository. if [ "$INSTALL_TELLSTICK" == "yes" ]; then virtualization/Docker/scripts/tellstick fi @@ -46,10 +55,6 @@ if [ "$INSTALL_OPENALPR" == "yes" ]; then virtualization/Docker/scripts/openalpr fi -if [ "$INSTALL_FFMPEG" == "yes" ]; then - virtualization/Docker/scripts/ffmpeg -fi - if [ "$INSTALL_LIBCEC" == "yes" ]; then virtualization/Docker/scripts/libcec fi