diff --git a/.coveragerc b/.coveragerc index d5eb32e670c28..3ecf2411384c7 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 @@ -11,6 +13,9 @@ omit = homeassistant/components/abode.py homeassistant/components/*/abode.py + homeassistant/components/ads/__init__.py + homeassistant/components/*/ads.py + homeassistant/components/alarmdecoder.py homeassistant/components/*/alarmdecoder.py @@ -26,6 +31,9 @@ omit = homeassistant/components/arduino.py homeassistant/components/*/arduino.py + homeassistant/components/bmw_connected_drive/*.py + homeassistant/components/*/bmw_connected_drive.py + homeassistant/components/android_ip_webcam.py homeassistant/components/*/android_ip_webcam.py @@ -35,6 +43,9 @@ omit = homeassistant/components/asterisk_mbox.py homeassistant/components/*/asterisk_mbox.py + homeassistant/components/august.py + homeassistant/components/*/august.py + homeassistant/components/axis.py homeassistant/components/*/axis.py @@ -47,12 +58,26 @@ omit = homeassistant/components/bloomsky.py homeassistant/components/*/bloomsky.py + homeassistant/components/coinbase.py + homeassistant/components/sensor/coinbase.py + homeassistant/components/comfoconnect.py homeassistant/components/*/comfoconnect.py + homeassistant/components/daikin.py + homeassistant/components/*/daikin.py + + homeassistant/components/deconz/* + homeassistant/components/*/deconz.py + homeassistant/components/digital_ocean.py homeassistant/components/*/digital_ocean.py + homeassistant/components/dominos.py + + homeassistant/components/doorbird.py + homeassistant/components/*/doorbird.py + homeassistant/components/dweet.py homeassistant/components/*/dweet.py @@ -62,30 +87,57 @@ omit = homeassistant/components/ecobee.py homeassistant/components/*/ecobee.py + homeassistant/components/egardia.py + homeassistant/components/*/egardia.py + homeassistant/components/enocean.py homeassistant/components/*/enocean.py 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 + homeassistant/components/google.py homeassistant/components/*/google.py homeassistant/components/hdmi_cec.py homeassistant/components/*/hdmi_cec.py - homeassistant/components/homematic.py + 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/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 homeassistant/components/*/ios.py + homeassistant/components/iota.py + homeassistant/components/*/iota.py + homeassistant/components/isy994.py homeassistant/components/*/isy994.py @@ -101,9 +153,15 @@ 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 + homeassistant/components/linode.py + homeassistant/components/*/linode.py + homeassistant/components/lutron.py homeassistant/components/*/lutron.py @@ -113,6 +171,9 @@ 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 @@ -122,6 +183,9 @@ omit = homeassistant/components/modbus.py homeassistant/components/*/modbus.py + homeassistant/components/mychevy.py + homeassistant/components/*/mychevy.py + homeassistant/components/mysensors.py homeassistant/components/*/mysensors.py @@ -140,12 +204,21 @@ omit = homeassistant/components/opencv.py homeassistant/components/*/opencv.py - homeassistant/components/qwikswitch.py - homeassistant/components/*/qwikswitch.py + homeassistant/components/pilight.py + homeassistant/components/*/pilight.py + + homeassistant/components/switch/qwikswitch.py + homeassistant/components/light/qwikswitch.py homeassistant/components/rachio.py homeassistant/components/*/rachio.py + homeassistant/components/raincloud.py + homeassistant/components/*/raincloud.py + + homeassistant/components/rainmachine.py + homeassistant/components/*/rainmachine.py + homeassistant/components/raspihats.py homeassistant/components/*/raspihats.py @@ -158,12 +231,27 @@ 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 + homeassistant/components/scsgate.py homeassistant/components/*/scsgate.py + homeassistant/components/skybell.py + homeassistant/components/*/skybell.py + + homeassistant/components/smappee.py + homeassistant/components/*/smappee.py + homeassistant/components/tado.py homeassistant/components/*/tado.py + homeassistant/components/tahoma.py + homeassistant/components/*/tahoma.py + homeassistant/components/tellduslive.py homeassistant/components/*/tellduslive.py @@ -173,8 +261,14 @@ omit = homeassistant/components/tesla.py homeassistant/components/*/tesla.py + homeassistant/components/thethingsnetwork.py + homeassistant/components/*/thethingsnetwork.py + homeassistant/components/*/thinkingcleaner.py + homeassistant/components/toon.py + homeassistant/components/*/toon.py + homeassistant/components/tradfri.py homeassistant/components/*/tradfri.py @@ -182,6 +276,9 @@ omit = homeassistant/components/notify/twilio_sms.py homeassistant/components/notify/twilio_call.py + homeassistant/components/upcloud.py + homeassistant/components/*/upcloud.py + homeassistant/components/usps.py homeassistant/components/*/usps.py @@ -200,20 +297,21 @@ omit = homeassistant/components/volvooncall.py homeassistant/components/*/volvooncall.py + homeassistant/components/waterfurnace.py + homeassistant/components/*/waterfurnace.py + homeassistant/components/*/webostv.py homeassistant/components/wemo.py homeassistant/components/*/wemo.py - homeassistant/components/wink.py + homeassistant/components/wink/* homeassistant/components/*/wink.py - homeassistant/components/xiaomi.py - homeassistant/components/binary_sensor/xiaomi.py - homeassistant/components/cover/xiaomi.py - homeassistant/components/light/xiaomi.py - homeassistant/components/sensor/xiaomi.py - homeassistant/components/switch/xiaomi.py + homeassistant/components/xiaomi_aqara.py + homeassistant/components/*/xiaomi_aqara.py + + homeassistant/components/*/xiaomi_miio.py homeassistant/components/zabbix.py homeassistant/components/*/zabbix.py @@ -229,8 +327,10 @@ omit = homeassistant/components/*/zoneminder.py homeassistant/components/alarm_control_panel/alarmdotcom.py + homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py - homeassistant/components/alarm_control_panel/egardia.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 @@ -242,28 +342,41 @@ omit = homeassistant/components/binary_sensor/hikvision.py homeassistant/components/binary_sensor/iss.py homeassistant/components/binary_sensor/mystrom.py - homeassistant/components/binary_sensor/pilight.py homeassistant/components/binary_sensor/ping.py homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py homeassistant/components/browser.py + homeassistant/components/calendar/caldav.py + 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/rpi_camera.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 + homeassistant/components/camera/xeoma.py + homeassistant/components/camera/yi.py + homeassistant/components/climate/econet.py + homeassistant/components/climate/ephember.py homeassistant/components/climate/eq3btsmart.py homeassistant/components/climate/flexit.py homeassistant/components/climate/heatmiser.py homeassistant/components/climate/homematic.py + homeassistant/components/climate/honeywell.py homeassistant/components/climate/knx.py homeassistant/components/climate/oem.py homeassistant/components/climate/proliphix.py homeassistant/components/climate/radiotherm.py homeassistant/components/climate/sensibo.py + 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 @@ -279,10 +392,14 @@ omit = homeassistant/components/device_tracker/bluetooth_tracker.py homeassistant/components/device_tracker/bt_home_hub_5.py 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 homeassistant/components/device_tracker/icloud.py + homeassistant/components/device_tracker/keenetic_ndms2.py homeassistant/components/device_tracker/linksys_ap.py homeassistant/components/device_tracker/linksys_smart.py homeassistant/components/device_tracker/luci.py @@ -293,9 +410,10 @@ omit = homeassistant/components/device_tracker/sky_hub.py homeassistant/components/device_tracker/snmp.py homeassistant/components/device_tracker/swisscom.py + homeassistant/components/device_tracker/tado.py homeassistant/components/device_tracker/thomson.py + homeassistant/components/device_tracker/tile.py homeassistant/components/device_tracker/tomato.py - homeassistant/components/device_tracker/tado.py homeassistant/components/device_tracker/tplink.py homeassistant/components/device_tracker/trackr.py homeassistant/components/device_tracker/ubus.py @@ -303,49 +421,54 @@ omit = homeassistant/components/emoncms_history.py homeassistant/components/emulated_hue/upnp.py homeassistant/components/fan/mqtt.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/blinkt.py homeassistant/components/light/blinksticklight.py - homeassistant/components/light/decora.py + homeassistant/components/light/blinkt.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/lifx.py + homeassistant/components/light/iglo.py homeassistant/components/light/lifx_legacy.py + homeassistant/components/light/lifx.py homeassistant/components/light/limitlessled.py homeassistant/components/light/mystrom.py + homeassistant/components/light/nanoleaf_aurora.py homeassistant/components/light/osramlightify.py - homeassistant/components/light/rpi_gpio_pwm.py homeassistant/components/light/piglow.py + homeassistant/components/light/rpi_gpio_pwm.py homeassistant/components/light/sensehat.py homeassistant/components/light/tikteck.py homeassistant/components/light/tplink.py homeassistant/components/light/tradfri.py homeassistant/components/light/x10.py - homeassistant/components/light/xiaomi_philipslight.py homeassistant/components/light/yeelight.py homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py homeassistant/components/lirc.py + homeassistant/components/lock/lockitron.py homeassistant/components/lock/nello.py homeassistant/components/lock/nuki.py - homeassistant/components/lock/lockitron.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 @@ -361,6 +484,7 @@ omit = homeassistant/components/media_player/kodi.py homeassistant/components/media_player/lg_netcast.py homeassistant/components/media_player/liveboxplaytv.py + homeassistant/components/media_player/mediaroom.py homeassistant/components/media_player/mpchc.py homeassistant/components/media_player/mpd.py homeassistant/components/media_player/nad.py @@ -375,24 +499,28 @@ omit = homeassistant/components/media_player/roku.py homeassistant/components/media_player/russound_rio.py homeassistant/components/media_player/russound_rnet.py - homeassistant/components/media_player/samsungtv.py homeassistant/components/media_player/snapcast.py + homeassistant/components/media_player/songpal.py homeassistant/components/media_player/sonos.py homeassistant/components/media_player/spotify.py homeassistant/components/media_player/squeezebox.py + homeassistant/components/media_player/ue_smart_radio.py homeassistant/components/media_player/vizio.py homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py - homeassistant/components/media_player/yamaha.py + homeassistant/components/media_player/xiaomi_tv.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 homeassistant/components/notify/aws_sns.py homeassistant/components/notify/aws_sqs.py homeassistant/components/notify/ciscospark.py + homeassistant/components/notify/clickatell.py + homeassistant/components/notify/clicksend_tts.py homeassistant/components/notify/clicksend.py homeassistant/components/notify/discord.py - homeassistant/components/notify/facebook.py homeassistant/components/notify/free_mobile.py homeassistant/components/notify/gntp.py homeassistant/components/notify/group.py @@ -401,7 +529,7 @@ 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 @@ -412,22 +540,28 @@ omit = homeassistant/components/notify/pushover.py homeassistant/components/notify/pushsafer.py homeassistant/components/notify/rest.py + homeassistant/components/notify/rocketchat.py homeassistant/components/notify/sendgrid.py - homeassistant/components/notify/simplepush.py homeassistant/components/notify/slack.py 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 homeassistant/components/notify/telstra.py homeassistant/components/notify/twitter.py homeassistant/components/notify/xmpp.py + homeassistant/components/notify/yessssms.py homeassistant/components/nuimo_controller.py homeassistant/components/prometheus.py + homeassistant/components/rainbird.py + homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote/harmony.py homeassistant/components/remote/itach.py homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py homeassistant/components/sensor/airvisual.py + homeassistant/components/sensor/alpha_vantage.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py @@ -435,22 +569,24 @@ omit = homeassistant/components/sensor/bitcoin.py homeassistant/components/sensor/blockchain.py homeassistant/components/sensor/bme280.py + homeassistant/components/sensor/bme680.py homeassistant/components/sensor/bom.py homeassistant/components/sensor/broadlink.py homeassistant/components/sensor/buienradar.py - homeassistant/components/sensor/citybikes.py - homeassistant/components/sensor/coinmarketcap.py homeassistant/components/sensor/cert_expiry.py + homeassistant/components/sensor/citybikes.py homeassistant/components/sensor/comed_hourly_pricing.py homeassistant/components/sensor/cpuspeed.py 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 @@ -462,11 +598,14 @@ omit = homeassistant/components/sensor/etherscan.py homeassistant/components/sensor/fastdotcom.py homeassistant/components/sensor/fedex.py - homeassistant/components/sensor/fido.py + homeassistant/components/sensor/filesize.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 homeassistant/components/sensor/geizhals.py homeassistant/components/sensor/gitter.py homeassistant/components/sensor/glances.py @@ -474,24 +613,27 @@ omit = homeassistant/components/sensor/gpsd.py homeassistant/components/sensor/gtfs.py homeassistant/components/sensor/haveibeenpwned.py - homeassistant/components/sensor/hddtemp.py homeassistant/components/sensor/hp_ilo.py homeassistant/components/sensor/htu21d.py - homeassistant/components/sensor/hydroquebec.py - homeassistant/components/sensor/imap.py homeassistant/components/sensor/imap_email_content.py + homeassistant/components/sensor/imap.py homeassistant/components/sensor/influxdb.py + homeassistant/components/sensor/irish_rail_transport.py homeassistant/components/sensor/kwb.py + homeassistant/components/sensor/lacrosse.py homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py + homeassistant/components/sensor/luftdaten.py homeassistant/components/sensor/lyft.py homeassistant/components/sensor/metoffice.py homeassistant/components/sensor/miflora.py + homeassistant/components/sensor/mitemp_bt.py homeassistant/components/sensor/modem_callerid.py homeassistant/components/sensor/mopar.py homeassistant/components/sensor/mqtt_room.py homeassistant/components/sensor/mvglive.py + homeassistant/components/sensor/nederlandse_spoorwegen.py homeassistant/components/sensor/netdata.py homeassistant/components/sensor/neurio_energy.py homeassistant/components/sensor/nut.py @@ -506,49 +648,71 @@ omit = homeassistant/components/sensor/pi_hole.py 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 homeassistant/components/sensor/qnap.py 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_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 homeassistant/components/sensor/steam_online.py 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 homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py + 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 homeassistant/components/sensor/xbox_live.py - homeassistant/components/sensor/yweather.py homeassistant/components/sensor/zamg.py + homeassistant/components/sensor/zestimate.py homeassistant/components/shiftr.py homeassistant/components/spc.py homeassistant/components/switch/acer_projector.py homeassistant/components/switch/anel_pwrctrl.py homeassistant/components/switch/arest.py homeassistant/components/switch/broadlink.py + homeassistant/components/switch/deluge.py homeassistant/components/switch/digitalloggers.py homeassistant/components/switch/dlink.py homeassistant/components/switch/edimax.py @@ -559,30 +723,32 @@ omit = homeassistant/components/switch/mystrom.py homeassistant/components/switch/netio.py homeassistant/components/switch/orvibo.py - homeassistant/components/switch/pilight.py homeassistant/components/switch/pulseaudio_loopback.py - homeassistant/components/switch/rainmachine.py + homeassistant/components/switch/rainbird.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/wake_on_lan.py + homeassistant/components/switch/vesync.py homeassistant/components/telegram_bot/* homeassistant/components/thingspeak.py homeassistant/components/tts/amazon_polly.py + homeassistant/components/tts/baidu.py + homeassistant/components/tts/microsoft.py homeassistant/components/tts/picotts.py - homeassistant/components/upnp.py + homeassistant/components/vacuum/mqtt.py homeassistant/components/vacuum/roomba.py homeassistant/components/weather/bom.py homeassistant/components/weather/buienradar.py + homeassistant/components/weather/darksky.py homeassistant/components/weather/metoffice.py homeassistant/components/weather/openweathermap.py - homeassistant/components/weather/yweather.py homeassistant/components/weather/zamg.py homeassistant/components/zeroconf.py homeassistant/components/zwave/util.py - [report] # Regexes for lines to exclude from consideration exclude_lines = diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000..caff2fc5c1f7a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Ensure Docker script files uses LF to support Docker for Windows. +# Ensure "git config --global core.autocrlf input" before you clone +* text eol=lf +*.py whitespace=error + +*.ico binary +*.jpg binary +*.png binary +*.zip binary +*.mp3 binary diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index c570b5483609f..8772a136eb38a 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 with the issue:** + -**Home Assistant release (`hass --version`):** +**Last working Home Assistant release (if known):** -**Python release (`python3 --version`):** +**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 0000000000000..2c418c6f63e0b --- /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 dd030c73d1aeb..9a8e6812cf31a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,19 +11,19 @@ ``` ## Checklist: + - [ ] The code change is tested and works locally. + - [ ] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** If user exposed functionality or configuration variables are added/changed: - [ ] Documentation added/updated in [home-assistant.github.io](https://github.com/home-assistant/home-assistant.github.io) If the code communicates with devices, web services, or third-party tools: - - [ ] Local tests with `tox` run successfully. **Your PR cannot be merged unless tests pass** - [ ] 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 files were added to `.coveragerc`. If the code does not interact with devices: - - [ ] Local tests with `tox` run successfully. **Your PR cannot be merged unless tests pass** - [ ] Tests have been added to verify that the new code works. [ex-requir]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard.py#L14 diff --git a/.github/move.yml b/.github/move.yml new file mode 100644 index 0000000000000..e041083c9ae11 --- /dev/null +++ b/.github/move.yml @@ -0,0 +1,13 @@ +# Configuration for move-issues - https://github.com/dessant/move-issues + +# Delete the command comment. Ignored when the comment also contains other content +deleteCommand: true +# Close the source issue after moving +closeSourceIssue: true +# Lock the source issue after moving +lockSourceIssue: false +# Set custom aliases for targets +# aliases: +# r: repo +# or: owner/repo + diff --git a/.gitignore b/.gitignore index 87bc6990ce4e4..bf49a1b61c1fe 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,12 @@ Icon # Thumbnails ._* +# IntelliJ IDEA .idea +*.iml # pytest +.pytest_cache .cache # GITHUB Proposed Python stuff: @@ -74,6 +77,7 @@ pip-selfcheck.json venv .venv Pipfile* +share/* # vimmy stuff *.swp @@ -96,4 +100,10 @@ docs/build desktop.ini /home-assistant.pyproj /home-assistant.sln -/.vs/home-assistant/v14 +/.vs/* + +# mypy +/.mypy_cache/* + +# Secrets +.lokalise_token diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 49d8dace9a4b8..0000000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "homeassistant/components/frontend/www_static/home-assistant-polymer"] - path = homeassistant/components/frontend/www_static/home-assistant-polymer - url = https://github.com/home-assistant/home-assistant-polymer.git diff --git a/.travis.yml b/.travis.yml index fdc5650db22b1..b089d3f89be32 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,20 +6,18 @@ addons: matrix: fast_finish: true include: - - python: "3.4.2" + - python: "3.5.3" env: TOXENV=lint - - python: "3.4.2" - env: TOXENV=py34 - # - python: "3.5" - # env: TOXENV=typing - - python: "3.5" + - python: "3.5.3" + env: TOXENV=pylint + - python: "3.5.3" + env: TOXENV=typing + - python: "3.5.3" env: TOXENV=py35 - python: "3.6" env: TOXENV=py36 # - python: "3.6-dev" # env: TOXENV=py36 - - python: "3.4.2" - env: TOXENV=requirements # allow_failures: # - python: "3.5" # env: TOXENV=typing @@ -29,5 +27,16 @@ cache: - $HOME/.cache/pip install: pip install -U tox coveralls language: python -script: travis_wait tox +script: travis_wait 30 tox --develop +services: + - docker +before_deploy: + - docker pull lokalise/lokalise-cli@sha256:2198814ebddfda56ee041a4b427521757dd57f75415ea9693696a64c550cef21 +deploy: + skip_cleanup: true + provider: script + script: script/travis_deploy + on: + branch: dev + condition: $TOXENV = lint after_success: coveralls diff --git a/CODEOWNERS b/CODEOWNERS index 3c975ca386242..32639fed43c49 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -29,13 +29,88 @@ homeassistant/components/weblink.py @home-assistant/core homeassistant/components/websocket_api.py @home-assistant/core homeassistant/components/zone.py @home-assistant/core +# HomeAssistant developer Teams Dockerfile @home-assistant/docker virtualization/Docker/* @home-assistant/docker homeassistant/components/zwave/* @home-assistant/z-wave homeassistant/components/*/zwave.py @home-assistant/z-wave -# Indiviudal components +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 +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/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/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/*/rfxtrx.py @danielhiversen +homeassistant/components/tahoma.py @philklei +homeassistant/components/*/tahoma.py @philklei +homeassistant/components/tesla.py @zabuldon +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 a9c0c21d0d7fb..9ad922d70456c 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 f0d5accdf3d30..5081b4ba72199 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # This way, the development image and the production image are kept in sync. FROM python:3.6 -MAINTAINER Paulus Schoutsen +LABEL maintainer="Paulus Schoutsen " # Uncomment any of the following lines to disable the installation. #ENV INSTALL_TELLSTICK no @@ -11,7 +11,6 @@ MAINTAINER Paulus Schoutsen #ENV INSTALL_FFMPEG no #ENV INSTALL_LIBCEC no #ENV INSTALL_PHANTOMJS no -#ENV INSTALL_COAP_CLIENT no #ENV INSTALL_SSOCR no VOLUME /config @@ -25,11 +24,10 @@ RUN virtualization/Docker/setup_docker_prereqs # Install hass component dependencies COPY requirements_all.txt requirements_all.txt - -# Uninstall enum34 because some depenndecies install it but breaks Python 3.4+. +# Uninstall enum34 because some dependencies install it but breaks Python 3.4+. # See PR #8103 for more info. RUN pip3 install --no-cache-dir -r requirements_all.txt && \ - pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet + pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet cython # Copy source COPY . . diff --git a/MANIFEST.in b/MANIFEST.in index 6f8652fe270e1..490b550e705e5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,4 @@ include README.rst include LICENSE.md graft homeassistant -prune homeassistant/components/frontend/www_static/home-assistant-polymer recursive-exclude * *.py[co] diff --git a/docs/screenshot-components.png b/docs/screenshot-components.png old mode 100755 new mode 100644 index 247f3073a5e22..a98b3d41ab9b0 Binary files a/docs/screenshot-components.png and b/docs/screenshot-components.png differ diff --git a/docs/screenshots.png b/docs/screenshots.png index 2a8a94e86b7dc..1305cddbb9dfe 100644 Binary files a/docs/screenshots.png and b/docs/screenshots.png differ diff --git a/docs/source/_static/logo-apple.png b/docs/source/_static/logo-apple.png index 20117d00f2275..03b5dd7780c0e 100644 Binary files a/docs/source/_static/logo-apple.png and b/docs/source/_static/logo-apple.png differ diff --git a/docs/source/_static/logo.png b/docs/source/_static/logo.png index 2959efdf89d84..3cd8005a1662a 100644 Binary files a/docs/source/_static/logo.png and b/docs/source/_static/logo.png differ diff --git a/docs/source/_templates/links.html b/docs/source/_templates/links.html index 272809d192086..53a8d1e425d70 100644 --- a/docs/source/_templates/links.html +++ b/docs/source/_templates/links.html @@ -2,5 +2,5 @@
  • Homepage
  • Community Forums
  • GitHub
  • -
  • Gitter
  • +
  • Discord
  • diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst index e31a1c9812970..fb61cd94fe622 100644 --- a/docs/source/api/util.rst +++ b/docs/source/api/util.rst @@ -4,10 +4,10 @@ homeassistant.util package Submodules ---------- -homeassistant.util.async module +homeassistant.util.async_ module ------------------------------- -.. automodule:: homeassistant.util.async +.. automodule:: homeassistant.util.async_ :members: :undoc-members: :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py index bcb2699f57b37..b5428ede8fa10 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -19,14 +19,25 @@ # import sys import os -from os.path import relpath import inspect -from homeassistant.const import (__version__, __short_version__, PROJECT_NAME, - PROJECT_LONG_DESCRIPTION, - PROJECT_COPYRIGHT, PROJECT_AUTHOR, - PROJECT_GITHUB_USERNAME, - PROJECT_GITHUB_REPOSITORY, - GITHUB_PATH, GITHUB_URL) + +from homeassistant.const import __version__, __short_version__ + +PROJECT_NAME = 'Home Assistant' +PROJECT_PACKAGE_NAME = 'homeassistant' +PROJECT_AUTHOR = 'The Home Assistant Authors' +PROJECT_COPYRIGHT = ' 2013-2018, {}'.format(PROJECT_AUTHOR) +PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source ' + 'home automation platform running on Python 3. ' + 'Track and control all devices at home and ' + 'automate control. ' + 'Installation in less than a minute.') +PROJECT_GITHUB_USERNAME = 'home-assistant' +PROJECT_GITHUB_REPOSITORY = 'home-assistant' + +GITHUB_PATH = '{}/{}'.format( + PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY) +GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) sys.path.insert(0, os.path.abspath('_ext')) @@ -87,9 +98,7 @@ def linkcode_resolve(domain, info): - """ - Determine the URL corresponding to Python object - """ + """Determine the URL corresponding to Python object.""" if domain != 'py': return None modname = info['module'] @@ -117,7 +126,11 @@ def linkcode_resolve(domain, info): linespec = "#L%d" % (lineno + 1) else: linespec = "" - fn = relpath(fn, start='../') + index = fn.find("/homeassistant/") + if index == -1: + index = 0 + + fn = fn[index:] return '{}/blob/{}/{}{}'.format(GITHUB_URL, code_branch, fn, linespec) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 2ce574ca15e58..7d3d2d2af8885 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -8,14 +8,14 @@ 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 ( __version__, EVENT_HOMEASSISTANT_START, REQUIRED_PYTHON_VER, - REQUIRED_PYTHON_VER_WIN, RESTART_EXIT_CODE, ) @@ -33,12 +33,7 @@ def attempt_use_uvloop(): def validate_python() -> None: """Validate that the right Python version is running.""" - if sys.platform == "win32" and \ - sys.version_info[:3] < REQUIRED_PYTHON_VER_WIN: - print("Home Assistant requires at least Python {}.{}.{}".format( - *REQUIRED_PYTHON_VER_WIN)) - sys.exit(1) - elif sys.version_info[:3] < REQUIRED_PYTHON_VER: + if sys.version_info[:3] < REQUIRED_PYTHON_VER: print("Home Assistant requires at least Python {}.{}.{}".format( *REQUIRED_PYTHON_VER)) sys.exit(1) @@ -126,6 +121,16 @@ def get_arguments() -> argparse.Namespace: type=int, default=None, help='Enables daily log rotation and keeps up to the specified days') + parser.add_argument( + '--log-file', + type=str, + 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', @@ -176,7 +181,8 @@ def check_pid(pid_file: str) -> None: """Check that Home Assistant is not already running.""" # Check pid file try: - pid = int(open(pid_file, 'r').readline()) + with open(pid_file, 'r') as file: + pid = int(file.readline()) except IOError: # PID File does not exist return @@ -198,7 +204,8 @@ def write_pid(pid_file: str) -> None: """Create a PID File.""" pid = os.getpid() try: - open(pid_file, 'w').write(str(pid)) + with open(pid_file, 'w') as file: + file.write(str(pid)) except IOError: print('Fatal Error: Unable to write pid file {}'.format(pid_file)) sys.exit(1) @@ -224,7 +231,7 @@ def closefds_osx(min_fd: int, max_fd: int) -> None: def cmdline() -> List[str]: """Collect path and arguments to re-execute the current hass instance.""" - if sys.argv[0].endswith(os.path.sep + '__main__.py'): + if os.path.basename(sys.argv[0]) == '__main__.py': modulepath = os.path.dirname(sys.argv[0]) os.environ['PYTHONPATH'] = os.path.dirname(modulepath) return [sys.executable] + [arg for arg in sys.argv if @@ -253,23 +260,25 @@ 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) + skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, + 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_rotate_days=args.log_rotate_days, log_file=args.log_file, + log_no_color=args.log_no_color) if hass is None: return None if args.open_ui: # Imported here to avoid importing asyncio before monkey patch - from homeassistant.util.async import run_callback_threadsafe + from homeassistant.util.async_ import run_callback_threadsafe def open_browser(event): """Open the webinterface in a browser.""" @@ -332,7 +341,8 @@ def main() -> int: """Start Home Assistant.""" validate_python() - if os.environ.get('HASS_NO_MONKEY') != '1': + monkey_patch_needed = sys.version_info[:3] < (3, 6, 3) + if monkey_patch_needed and os.environ.get('HASS_NO_MONKEY') != '1': if sys.version_info[:2] >= (3, 6): monkey_patch.disable_c_asyncio() monkey_patch.patch_weakref_tasks() diff --git a/homeassistant/auth.py b/homeassistant/auth.py new file mode 100644 index 0000000000000..7c01776b7b1c9 --- /dev/null +++ b/homeassistant/auth.py @@ -0,0 +1,500 @@ +"""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.""" + 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 0000000000000..4705e7580ca44 --- /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 0000000000000..c2db193ce1a1a --- /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 0000000000000..a8e8cd0cb0e12 --- /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 7831036ff597e..a405362d368d8 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -11,13 +11,11 @@ import voluptuous as vol -import homeassistant.components as core_components +from homeassistant import ( + core, config as conf_util, config_entries, components as core_components) from homeassistant.components import persistent_notification -import homeassistant.config as conf_util -import homeassistant.core as core from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.setup import async_setup_component -import homeassistant.loader as loader from homeassistant.util.logging import AsyncHandler from homeassistant.util.package import async_get_user_site, get_user_site from homeassistant.util.yaml import clear_secret_cache @@ -27,18 +25,24 @@ _LOGGER = logging.getLogger(__name__) ERROR_LOG_FILENAME = 'home-assistant.log' + +# hass.data key for logging information. +DATA_LOGGING = 'logging' + FIRST_INIT_COMPONENT = set(( - 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', 'introduction', - 'frontend', 'history')) + 'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', + 'introduction', 'frontend', 'history')) def from_config_dict(config: Dict[str, Any], - hass: Optional[core.HomeAssistant]=None, - config_dir: Optional[str]=None, - enable_log: bool=True, - verbose: bool=False, - skip_pip: bool=False, - log_rotate_days: Any=None) \ + hass: Optional[core.HomeAssistant] = None, + 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. @@ -56,20 +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_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) \ +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. @@ -77,52 +82,55 @@ def async_from_config_dict(config: Dict[str, Any], This method is a coroutine. """ start = time() + + if enable_log: + 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) - - if enable_log: - async_enable_logging(hass, verbose, log_rotate_days) + 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, {})) - # Make a copy because we are mutating it. - # Use OrderedDict in case original one was one. - # Convert values to dictionaries if they are None - new_config = OrderedDict() + # Ensure we have no None values after merge for key, value in config.items(): - new_config[key] = value or {} - config = new_config + if not value: + config[key] = {} + + hass.config_entries = config_entries.ConfigEntries(hass, config) + 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() if key != core.DOMAIN) + components.update(hass.config_entries.async_domains()) # 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") @@ -132,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: @@ -140,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) @@ -150,10 +158,12 @@ def async_from_config_dict(config: Dict[str, Any], def from_config_file(config_path: str, - hass: Optional[core.HomeAssistant]=None, - verbose: bool=False, - skip_pip: bool=True, - log_rotate_days: Any=None): + hass: Optional[core.HomeAssistant] = None, + 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 if given, @@ -165,18 +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) + 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): +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. @@ -185,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) + 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) @@ -198,58 +211,75 @@ 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) -> 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 - err_log_path = hass.config.path(ERROR_LOG_FILENAME) + if log_file is None: + err_log_path = hass.config.path(ERROR_LOG_FILENAME) + else: + err_log_path = os.path.abspath(log_file) + err_path_exists = os.path.isfile(err_log_path) + err_dir = os.path.dirname(err_log_path) # Check if we can write to the error log if it exists or that # we can create files in the containing directory if not. if (err_path_exists and os.access(err_log_path, os.W_OK)) or \ - (not err_path_exists and os.access(hass.config.config_dir, os.W_OK)): + (not err_path_exists and os.access(err_dir, os.W_OK)): 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) @@ -259,19 +289,20 @@ 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. + hass.data[DATA_LOGGING] = err_log_path else: _LOGGER.error( "Unable to setup error log %s (access denied)", err_log_path) @@ -286,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/__init__.py b/homeassistant/components/__init__.py index 6db147a5f5932..f0c4f7bb3e238 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -15,6 +15,7 @@ import homeassistant.config as conf_util from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers import intent from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, @@ -133,7 +134,7 @@ def async_handle_turn_service(service): # have been processed. If a service does not exist it causes a 10 # second delay while we're blocking waiting for a response. # But services can be registered on other HA instances that are - # listening to the bus too. So as a in between solution, we'll + # listening to the bus too. So as an in between solution, we'll # block only if the service is defined in the current HA instance. blocking = hass.services.has_service(domain, service.service) @@ -154,6 +155,13 @@ def async_handle_turn_service(service): ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) hass.services.async_register( ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned {} on")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, + "Turned {} off")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}")) @asyncio.coroutine def async_handle_core_service(call): diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index f3283eff74865..6d5feb87dc2b1 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -6,57 +6,137 @@ """ import asyncio import logging +from functools import partial +from requests.exceptions import HTTPError, ConnectTimeout import voluptuous as vol -from requests.exceptions import HTTPError, ConnectTimeout -from homeassistant.helpers import discovery + +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, ATTR_ENTITY_ID, CONF_USERNAME, + CONF_PASSWORD, CONF_EXCLUDE, CONF_NAME, CONF_LIGHTS, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from homeassistant.const import (ATTR_ATTRIBUTION, - CONF_USERNAME, CONF_PASSWORD, - CONF_NAME, EVENT_HOMEASSISTANT_STOP, - EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['abodepy==0.9.0'] +REQUIREMENTS = ['abodepy==0.13.1'] _LOGGER = logging.getLogger(__name__) CONF_ATTRIBUTION = "Data provided by goabode.com" +CONF_POLLING = 'polling' DOMAIN = 'abode' -DEFAULT_NAME = 'Abode' -DATA_ABODE = 'abode' +DEFAULT_CACHEDB = './abodepy_cache.pickle' NOTIFICATION_ID = 'abode_notification' NOTIFICATION_TITLE = 'Abode Security Setup' +EVENT_ABODE_ALARM = 'abode_alarm' +EVENT_ABODE_ALARM_END = 'abode_alarm_end' +EVENT_ABODE_AUTOMATION = 'abode_automation' +EVENT_ABODE_FAULT = 'abode_panel_fault' +EVENT_ABODE_RESTORE = 'abode_panel_restore' + +SERVICE_SETTINGS = 'change_setting' +SERVICE_CAPTURE_IMAGE = 'capture_image' +SERVICE_TRIGGER = 'trigger_quick_action' + +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_NAME = 'device_name' +ATTR_DEVICE_TYPE = 'device_type' +ATTR_EVENT_CODE = 'event_code' +ATTR_EVENT_NAME = 'event_name' +ATTR_EVENT_TYPE = 'event_type' +ATTR_EVENT_UTC = 'event_utc' +ATTR_SETTING = 'setting' +ATTR_USER_NAME = 'user_name' +ATTR_VALUE = 'value' + +ABODE_DEVICE_ID_LIST_SCHEMA = vol.Schema([str]) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_POLLING, default=False): cv.boolean, + vol.Optional(CONF_EXCLUDE, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA, + vol.Optional(CONF_LIGHTS, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA }), }, extra=vol.ALLOW_EXTRA) +CHANGE_SETTING_SCHEMA = vol.Schema({ + vol.Required(ATTR_SETTING): cv.string, + vol.Required(ATTR_VALUE): cv.string +}) + +CAPTURE_IMAGE_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + +TRIGGER_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + ABODE_PLATFORMS = [ - 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover' + 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover', + 'camera', 'light', 'sensor' ] +class AbodeSystem(object): + """Abode System class.""" + + 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, cache_path=cache) + self.name = name + self.polling = polling + self.exclude = exclude + self.lights = lights + self.devices = [] + + def is_excluded(self, device): + """Check if a device is configured to be excluded.""" + return device.device_id in self.exclude + + def is_automation_excluded(self, automation): + """Check if an automation is configured to be excluded.""" + return automation.automation_id in self.exclude + + def is_light(self, device): + """Check if a switch device is configured as a light.""" + import abodepy.helpers.constants as CONST + + return (device.generic_type == CONST.TYPE_LIGHT or + (device.generic_type == CONST.TYPE_SWITCH and + device.device_id in self.lights)) + + def setup(hass, config): """Set up Abode component.""" - import abodepy + from abodepy.exceptions import AbodeException conf = config[DOMAIN] username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) + name = conf.get(CONF_NAME) + polling = conf.get(CONF_POLLING) + exclude = conf.get(CONF_EXCLUDE) + lights = conf.get(CONF_LIGHTS) try: - hass.data[DATA_ABODE] = abode = abodepy.Abode( - username, password, auto_login=True, get_devices=True) - - except (ConnectTimeout, HTTPError) as ex: + cache = hass.config.path(DEFAULT_CACHEDB) + hass.data[DOMAIN] = AbodeSystem( + username, password, cache, name, polling, exclude, lights) + except (AbodeException, ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Abode: %s", str(ex)) + hass.components.persistent_notification.create( 'Error: {}
    ' 'You will need to restart hass after fixing.' @@ -65,46 +145,138 @@ def setup(hass, config): notification_id=NOTIFICATION_ID) return False + setup_hass_services(hass) + setup_hass_events(hass) + setup_abode_events(hass) + for platform in ABODE_PLATFORMS: discovery.load_platform(hass, platform, DOMAIN, {}, config) + return True + + +def setup_hass_services(hass): + """Home assistant services.""" + from abodepy.exceptions import AbodeException + + def change_setting(call): + """Change an Abode system setting.""" + setting = call.data.get(ATTR_SETTING) + value = call.data.get(ATTR_VALUE) + + try: + hass.data[DOMAIN].abode.set_setting(setting, value) + except AbodeException as ex: + _LOGGER.warning(ex) + + def capture_image(call): + """Capture a new image.""" + entity_ids = call.data.get(ATTR_ENTITY_ID) + + target_devices = [device for device in hass.data[DOMAIN].devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.capture() + + def trigger_quick_action(call): + """Trigger a quick action.""" + entity_ids = call.data.get(ATTR_ENTITY_ID, None) + + target_devices = [device for device in hass.data[DOMAIN].devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.trigger() + + hass.services.register( + DOMAIN, SERVICE_SETTINGS, change_setting, + schema=CHANGE_SETTING_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, + schema=CAPTURE_IMAGE_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_TRIGGER, trigger_quick_action, + schema=TRIGGER_SCHEMA) + + +def setup_hass_events(hass): + """Home Assistant start and stop callbacks.""" + def startup(event): + """Listen for push events.""" + hass.data[DOMAIN].abode.events.start() + def logout(event): """Logout of Abode.""" - abode.stop_listener() - abode.logout() + if not hass.data[DOMAIN].polling: + hass.data[DOMAIN].abode.events.stop() + + hass.data[DOMAIN].abode.logout() _LOGGER.info("Logged out of Abode") + if not hass.data[DOMAIN].polling: + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout) - def startup(event): - """Listen for push events.""" - abode.start_listener() - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) +def setup_abode_events(hass): + """Event callbacks.""" + import abodepy.helpers.timeline as TIMELINE - return True + def event_callback(event, event_json): + """Handle an event callback from Abode.""" + data = { + ATTR_DEVICE_ID: event_json.get(ATTR_DEVICE_ID, ''), + ATTR_DEVICE_NAME: event_json.get(ATTR_DEVICE_NAME, ''), + ATTR_DEVICE_TYPE: event_json.get(ATTR_DEVICE_TYPE, ''), + ATTR_EVENT_CODE: event_json.get(ATTR_EVENT_CODE, ''), + ATTR_EVENT_NAME: event_json.get(ATTR_EVENT_NAME, ''), + ATTR_EVENT_TYPE: event_json.get(ATTR_EVENT_TYPE, ''), + ATTR_EVENT_UTC: event_json.get(ATTR_EVENT_UTC, ''), + ATTR_USER_NAME: event_json.get(ATTR_USER_NAME, ''), + ATTR_DATE: event_json.get(ATTR_DATE, ''), + ATTR_TIME: event_json.get(ATTR_TIME, ''), + } + + hass.bus.fire(event, data) + + events = [TIMELINE.ALARM_GROUP, TIMELINE.ALARM_END_GROUP, + TIMELINE.PANEL_FAULT_GROUP, TIMELINE.PANEL_RESTORE_GROUP, + TIMELINE.AUTOMATION_GROUP] + + for event in events: + hass.data[DOMAIN].abode.events.add_event_callback( + event, + partial(event_callback, event)) class AbodeDevice(Entity): """Representation of an Abode device.""" - def __init__(self, controller, device): + def __init__(self, data, device): """Initialize a sensor for Abode device.""" - self._controller = controller + self._data = data self._device = device @asyncio.coroutine def async_added_to_hass(self): """Subscribe Abode events.""" self.hass.async_add_job( - self._controller.register, self._device, - self._update_callback + self._data.abode.events.add_device_callback, + self._device.device_id, self._update_callback ) @property def should_poll(self): """Return the polling state.""" - return False + return self._data.polling + + def update(self): + """Update automation state.""" + self._device.refresh() @property def name(self): @@ -118,9 +290,58 @@ def device_state_attributes(self): ATTR_ATTRIBUTION: CONF_ATTRIBUTION, 'device_id': self._device.device_id, 'battery_low': self._device.battery_low, - 'no_response': self._device.no_response + 'no_response': self._device.no_response, + 'device_type': self._device.type + } + + def _update_callback(self, device): + """Update the device state.""" + self.schedule_update_ha_state() + + +class AbodeAutomation(Entity): + """Representation of an Abode automation.""" + + def __init__(self, data, automation, event=None): + """Initialize for Abode automation.""" + self._data = data + self._automation = automation + self._event = event + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + if self._event: + self.hass.async_add_job( + self._data.abode.events.add_event_callback, + self._event, self._update_callback + ) + + @property + def should_poll(self): + """Return the polling state.""" + return self._data.polling + + def update(self): + """Update automation state.""" + self._automation.refresh() + + @property + def name(self): + """Return the name of the sensor.""" + return self._automation.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'automation_id': self._automation.automation_id, + 'type': self._automation.type, + 'sub_type': self._automation.sub_type } def _update_callback(self, device): """Update the device state.""" + self._automation.refresh() self.schedule_update_ha_state() diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py new file mode 100644 index 0000000000000..d603843f51f3f --- /dev/null +++ b/homeassistant/components/ads/__init__.py @@ -0,0 +1,196 @@ +""" +Support for Automation Device Specification (ADS). + +For more details about this component, please refer to the documentation. +https://home-assistant.io/components/ads/ +""" +import threading +import struct +import logging +import ctypes +from collections import namedtuple +import voluptuous as vol +from homeassistant.const import CONF_DEVICE, CONF_PORT, CONF_IP_ADDRESS, \ + EVENT_HOMEASSISTANT_STOP +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyads==2.2.6'] + +_LOGGER = logging.getLogger(__name__) + +DATA_ADS = 'data_ads' + +# Supported Types +ADSTYPE_INT = 'int' +ADSTYPE_UINT = 'uint' +ADSTYPE_BYTE = 'byte' +ADSTYPE_BOOL = 'bool' + +DOMAIN = 'ads' + +CONF_ADS_VAR = 'adsvar' +CONF_ADS_VAR_BRIGHTNESS = 'adsvar_brightness' +CONF_ADS_TYPE = 'adstype' +CONF_ADS_FACTOR = 'factor' +CONF_ADS_VALUE = 'value' + +SERVICE_WRITE_DATA_BY_NAME = 'write_data_by_name' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Optional(CONF_IP_ADDRESS): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +SCHEMA_SERVICE_WRITE_DATA_BY_NAME = vol.Schema({ + vol.Required(CONF_ADS_TYPE): + vol.In([ADSTYPE_INT, ADSTYPE_UINT, ADSTYPE_BYTE]), + vol.Required(CONF_ADS_VALUE): cv.match_all, + vol.Required(CONF_ADS_VAR): cv.string, +}) + + +def setup(hass, config): + """Set up the ADS component.""" + import pyads + conf = config[DOMAIN] + + net_id = conf.get(CONF_DEVICE) + ip_address = conf.get(CONF_IP_ADDRESS) + port = conf.get(CONF_PORT) + + client = pyads.Connection(net_id, port, ip_address) + + AdsHub.ADS_TYPEMAP = { + ADSTYPE_BOOL: pyads.PLCTYPE_BOOL, + ADSTYPE_BYTE: pyads.PLCTYPE_BYTE, + ADSTYPE_INT: pyads.PLCTYPE_INT, + ADSTYPE_UINT: pyads.PLCTYPE_UINT, + } + + AdsHub.PLCTYPE_BOOL = pyads.PLCTYPE_BOOL + AdsHub.PLCTYPE_BYTE = pyads.PLCTYPE_BYTE + AdsHub.PLCTYPE_INT = pyads.PLCTYPE_INT + AdsHub.PLCTYPE_UINT = pyads.PLCTYPE_UINT + AdsHub.ADSError = pyads.ADSError + + try: + ads = AdsHub(client) + except pyads.pyads.ADSError: + _LOGGER.error( + "Could not connect to ADS host (netid=%s, port=%s)", net_id, port) + return False + + hass.data[DATA_ADS] = ads + hass.bus.listen(EVENT_HOMEASSISTANT_STOP, ads.shutdown) + + def handle_write_data_by_name(call): + """Write a value to the connected ADS device.""" + ads_var = call.data.get(CONF_ADS_VAR) + ads_type = call.data.get(CONF_ADS_TYPE) + value = call.data.get(CONF_ADS_VALUE) + + try: + ads.write_by_name(ads_var, value, ads.ADS_TYPEMAP[ads_type]) + except pyads.ADSError as err: + _LOGGER.error(err) + + hass.services.register( + DOMAIN, SERVICE_WRITE_DATA_BY_NAME, handle_write_data_by_name, + schema=SCHEMA_SERVICE_WRITE_DATA_BY_NAME) + + return True + + +# Tuple to hold data needed for notification +NotificationItem = namedtuple( + 'NotificationItem', 'hnotify huser name plc_datatype callback' +) + + +class AdsHub(object): + """Representation of an ADS connection.""" + + def __init__(self, ads_client): + """Initialize the ADS hub.""" + self._client = ads_client + self._client.open() + + # All ADS devices are registered here + self._devices = [] + self._notification_items = {} + self._lock = threading.Lock() + + def shutdown(self, *args, **kwargs): + """Shutdown ADS connection.""" + _LOGGER.debug("Shutting down ADS") + for notification_item in self._notification_items.values(): + self._client.del_device_notification( + notification_item.hnotify, + notification_item.huser + ) + _LOGGER.debug( + "Deleting device notification %d, %d", + notification_item.hnotify, notification_item.huser) + self._client.close() + + def register_device(self, device): + """Register a new device.""" + self._devices.append(device) + + def write_by_name(self, name, value, plc_datatype): + """Write a value to the device.""" + with self._lock: + return self._client.write_by_name(name, value, plc_datatype) + + def read_by_name(self, name, plc_datatype): + """Read a value from the device.""" + with self._lock: + return self._client.read_by_name(name, plc_datatype) + + def add_device_notification(self, name, plc_datatype, callback): + """Add a notification to the ADS devices.""" + from pyads import NotificationAttrib + attr = NotificationAttrib(ctypes.sizeof(plc_datatype)) + + with self._lock: + hnotify, huser = self._client.add_device_notification( + name, attr, self._device_notification_callback) + hnotify = int(hnotify) + + _LOGGER.debug( + "Added device notification %d for variable %s", hnotify, name) + + self._notification_items[hnotify] = NotificationItem( + hnotify, huser, name, plc_datatype, callback) + + def _device_notification_callback(self, addr, notification, huser): + """Handle device notifications.""" + contents = notification.contents + + hnotify = int(contents.hNotification) + _LOGGER.debug("Received notification %d", hnotify) + data = contents.data + + try: + notification_item = self._notification_items[hnotify] + except KeyError: + _LOGGER.debug("Unknown device notification handle: %d", hnotify) + return + + # Parse data to desired datatype + if notification_item.plc_datatype == self.PLCTYPE_BOOL: + value = bool(struct.unpack(' \ - dt_util.utcnow(): - return STATE_ALARM_PENDING - - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: - if self._state_ts + self._pending_time > dt_util.utcnow(): + if self._state == STATE_ALARM_TRIGGERED: + if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time + - self._trigger_time) < dt_util.utcnow(): + trigger_time = self._trigger_time_by_state[self._previous_state] + if (self._state_ts + self._pending_time(self._state) + + trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - else: - self._state = self._pre_trigger_state - return self._state + self._state = self._previous_state + return self._state + + if self._state in SUPPORTED_PENDING_STATES and \ + self._within_pending_time(self._state): + return STATE_ALARM_PENDING return self._state + @property + def _active_state(self): + """Get the current state.""" + if self.state == STATE_ALARM_PENDING: + return self._previous_state + return self._state + + def _pending_time(self, state): + """Get the pending time.""" + pending_time = self._pending_time_by_state[state] + if state == STATE_ALARM_TRIGGERED: + pending_time += self._delay_time_by_state[self._previous_state] + return pending_time + + def _within_pending_time(self, state): + """Get if the action is in the pending time window.""" + return self._state_ts + self._pending_time(state) > dt_util.utcnow() + @property def code_format(self): - """One or more characters.""" + """Return one or more characters.""" return None if self._code is None else '.+' def alarm_disarm(self, code=None): @@ -128,62 +218,75 @@ def alarm_arm_home(self, code=None): if not self._validate_code(code, STATE_ALARM_ARMED_HOME): return - self._state = STATE_ALARM_ARMED_HOME - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): return - self._state = STATE_ALARM_ARMED_AWAY - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_AWAY) def alarm_arm_night(self, code=None): """Send arm night command.""" if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): return - self._state = STATE_ALARM_ARMED_NIGHT - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() + self._update_state(STATE_ALARM_ARMED_NIGHT) - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + def alarm_arm_custom_bypass(self, code=None): + """Send arm custom bypass command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS): + return + + self._update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) def alarm_trigger(self, code=None): - """Send alarm trigger command. No code needed.""" - self._pre_trigger_state = self._state - self._state = STATE_ALARM_TRIGGERED + """ + Send alarm trigger command. + + No code needed, a trigger time of zero for the current state + disables the alarm. + """ + if not self._trigger_time_by_state[self._active_state]: + return + self._update_state(STATE_ALARM_TRIGGERED) + + def _update_state(self, state): + """Update the state.""" + if self._state == state: + return + + self._previous_state = self._state + self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - if self._trigger_time: + pending_time = self._pending_time(state) + if state == STATE_ALARM_TRIGGERED: track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._state_ts + pending_time) + trigger_time = self._trigger_time_by_state[self._previous_state] + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time + trigger_time) + elif state in SUPPORTED_PENDING_STATES and pending_time: track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time + self._trigger_time) + self._state_ts + pending_time) def _validate_code(self, code, state): """Validate given code.""" - check = self._code is None or code == self._code + if self._code is None: + return True + if isinstance(self._code, str): + alarm_code = self._code + else: + alarm_code = self._code.render(from_state=self._state, + to_state=state) + check = not alarm_code or code == alarm_code if not check: _LOGGER.warning("Invalid code given for %s", state) return check @@ -194,6 +297,7 @@ def device_state_attributes(self): state_attr = {} if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_PRE_PENDING_STATE] = self._previous_state state_attr[ATTR_POST_PENDING_STATE] = self._state return state_attr diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index b554a667b2a0c..4b08ad67292d2 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -5,6 +5,7 @@ https://home-assistant.io/components/alarm_control_panel.manual_mqtt/ """ import asyncio +import copy import datetime import logging @@ -13,10 +14,10 @@ import homeassistant.components.alarm_control_panel as alarm import homeassistant.util.dt as dt_util from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, CONF_PLATFORM, - CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, - CONF_DISARM_AFTER_TRIGGER) + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, + CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_DELAY_TIME, CONF_PENDING_TIME, + CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER) import homeassistant.components.mqtt as mqtt from homeassistant.helpers.event import async_track_state_change @@ -25,38 +26,100 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_time +_LOGGER = logging.getLogger(__name__) + +CONF_CODE_TEMPLATE = 'code_template' + CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' +CONF_PAYLOAD_ARM_NIGHT = 'payload_arm_night' DEFAULT_ALARM_NAME = 'HA Alarm' -DEFAULT_PENDING_TIME = 60 -DEFAULT_TRIGGER_TIME = 120 +DEFAULT_DELAY_TIME = datetime.timedelta(seconds=0) +DEFAULT_PENDING_TIME = datetime.timedelta(seconds=60) +DEFAULT_TRIGGER_TIME = datetime.timedelta(seconds=120) DEFAULT_DISARM_AFTER_TRIGGER = False DEFAULT_ARM_AWAY = 'ARM_AWAY' DEFAULT_ARM_HOME = 'ARM_HOME' +DEFAULT_ARM_NIGHT = 'ARM_NIGHT' DEFAULT_DISARM = 'DISARM' +SUPPORTED_STATES = [STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_TRIGGERED] + +SUPPORTED_PRETRIGGER_STATES = [state for state in SUPPORTED_STATES + if state != STATE_ALARM_TRIGGERED] + +SUPPORTED_PENDING_STATES = [state for state in SUPPORTED_STATES + if state != STATE_ALARM_DISARMED] + +ATTR_PRE_PENDING_STATE = 'pre_pending_state' +ATTR_POST_PENDING_STATE = 'post_pending_state' + + +def _state_validator(config): + """Validate the state.""" + config = copy.deepcopy(config) + for state in SUPPORTED_PRETRIGGER_STATES: + if CONF_DELAY_TIME not in config[state]: + config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME] + if CONF_TRIGGER_TIME not in config[state]: + config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME] + for state in SUPPORTED_PENDING_STATES: + if CONF_PENDING_TIME not in config[state]: + config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + + return config + + +def _state_schema(state): + """Validate the state.""" + schema = {} + if state in SUPPORTED_PRETRIGGER_STATES: + schema[vol.Optional(CONF_DELAY_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + schema[vol.Optional(CONF_TRIGGER_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + if state in SUPPORTED_PENDING_STATES: + schema[vol.Optional(CONF_PENDING_TIME)] = vol.All( + cv.time_period, cv.positive_timedelta) + return vol.Schema(schema) + + DEPENDENCIES = ['mqtt'] -PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA = vol.Schema(vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): 'manual_mqtt', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, - vol.Optional(CONF_CODE): cv.string, + vol.Exclusive(CONF_CODE, 'code validation'): cv.string, + vol.Exclusive(CONF_CODE_TEMPLATE, 'code validation'): cv.template, + vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME): + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME): - vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): - vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): + _state_schema(STATE_ALARM_ARMED_AWAY), + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): + _state_schema(STATE_ALARM_ARMED_HOME), + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): + _state_schema(STATE_ALARM_ARMED_NIGHT), + vol.Optional(STATE_ALARM_DISARMED, default={}): + _state_schema(STATE_ALARM_DISARMED), + vol.Optional(STATE_ALARM_TRIGGERED, default={}): + _state_schema(STATE_ALARM_TRIGGERED), vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, + vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string, vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, -}) - -_LOGGER = logging.getLogger(__name__) +}), _state_validator)) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -65,15 +128,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass, config[CONF_NAME], config.get(CONF_CODE), - config.get(CONF_PENDING_TIME, DEFAULT_PENDING_TIME), - config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME), + config.get(CONF_CODE_TEMPLATE), config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), config.get(mqtt.CONF_STATE_TOPIC), config.get(mqtt.CONF_COMMAND_TOPIC), config.get(mqtt.CONF_QOS), config.get(CONF_PAYLOAD_DISARM), config.get(CONF_PAYLOAD_ARM_HOME), - config.get(CONF_PAYLOAD_ARM_AWAY))]) + config.get(CONF_PAYLOAD_ARM_AWAY), + config.get(CONF_PAYLOAD_ARM_NIGHT), + config)]) class ManualMQTTAlarm(alarm.AlarmControlPanel): @@ -81,32 +145,47 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): Representation of an alarm status. When armed, will be pending for 'pending_time', after that armed. - When triggered, will be pending for 'trigger_time'. After that will be - triggered for 'trigger_time', after that we return to the previous state - or disarm if `disarm_after_trigger` is true. + When triggered, will be pending for the triggering state's 'delay_time' + plus the triggered state's 'pending_time'. + After that will be triggered for 'trigger_time', after that we return to + the previous state or disarm if `disarm_after_trigger` is true. + A trigger_time of zero disables the alarm_trigger service. """ - def __init__(self, hass, name, code, pending_time, - trigger_time, disarm_after_trigger, - state_topic, command_topic, qos, - payload_disarm, payload_arm_home, payload_arm_away): + def __init__(self, hass, name, code, code_template, disarm_after_trigger, + state_topic, command_topic, qos, payload_disarm, + payload_arm_home, payload_arm_away, payload_arm_night, + config): """Init the manual MQTT alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass self._name = name - self._code = str(code) if code else None - self._pending_time = datetime.timedelta(seconds=pending_time) - self._trigger_time = datetime.timedelta(seconds=trigger_time) + if code_template: + self._code = code_template + self._code.hass = hass + else: + self._code = code or None self._disarm_after_trigger = disarm_after_trigger - self._pre_trigger_state = self._state + self._previous_state = self._state self._state_ts = None + self._delay_time_by_state = { + state: config[state][CONF_DELAY_TIME] + for state in SUPPORTED_PRETRIGGER_STATES} + self._trigger_time_by_state = { + state: config[state][CONF_TRIGGER_TIME] + for state in SUPPORTED_PRETRIGGER_STATES} + self._pending_time_by_state = { + state: config[state][CONF_PENDING_TIME] + for state in SUPPORTED_PENDING_STATES} + self._state_topic = state_topic self._command_topic = command_topic self._qos = qos self._payload_disarm = payload_disarm self._payload_arm_home = payload_arm_home self._payload_arm_away = payload_arm_away + self._payload_arm_night = payload_arm_night @property def should_poll(self): @@ -121,26 +200,44 @@ def name(self): @property def state(self): """Return the state of the device.""" - if self._state in (STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY) and \ - self._pending_time and self._state_ts + self._pending_time > \ - dt_util.utcnow(): - return STATE_ALARM_PENDING - - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: - if self._state_ts + self._pending_time > dt_util.utcnow(): + if self._state == STATE_ALARM_TRIGGERED: + if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time + - self._trigger_time) < dt_util.utcnow(): + trigger_time = self._trigger_time_by_state[self._previous_state] + if (self._state_ts + self._pending_time(self._state) + + trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - return self._pre_trigger_state + self._state = self._previous_state + return self._state + + if self._state in SUPPORTED_PENDING_STATES and \ + self._within_pending_time(self._state): + return STATE_ALARM_PENDING + + return self._state + @property + def _active_state(self): + """Get the current state.""" + if self.state == STATE_ALARM_PENDING: + return self._previous_state return self._state + def _pending_time(self, state): + """Get the pending time.""" + pending_time = self._pending_time_by_state[state] + if state == STATE_ALARM_TRIGGERED: + pending_time += self._delay_time_by_state[self._previous_state] + return pending_time + + def _within_pending_time(self, state): + """Get if the action is in the pending time window.""" + return self._state_ts + self._pending_time(state) > dt_util.utcnow() + @property def code_format(self): - """One or more characters.""" + """Return one or more characters.""" return None if self._code is None else '.+' def alarm_disarm(self, code=None): @@ -157,54 +254,85 @@ def alarm_arm_home(self, code=None): if not self._validate_code(code, STATE_ALARM_ARMED_HOME): return - self._state = STATE_ALARM_ARMED_HOME - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): return - self._state = STATE_ALARM_ARMED_AWAY - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() + self._update_state(STATE_ALARM_ARMED_AWAY) - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + def alarm_arm_night(self, code=None): + """Send arm night command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): + return + + self._update_state(STATE_ALARM_ARMED_NIGHT) def alarm_trigger(self, code=None): - """Send alarm trigger command. No code needed.""" - self._pre_trigger_state = self._state - self._state = STATE_ALARM_TRIGGERED + """ + Send alarm trigger command. + + No code needed, a trigger time of zero for the current state + disables the alarm. + """ + if not self._trigger_time_by_state[self._active_state]: + return + self._update_state(STATE_ALARM_TRIGGERED) + + def _update_state(self, state): + """Update the state.""" + if self._state == state: + return + + self._previous_state = self._state + self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - if self._trigger_time: + pending_time = self._pending_time(state) + if state == STATE_ALARM_TRIGGERED: track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._state_ts + pending_time) + trigger_time = self._trigger_time_by_state[self._previous_state] track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time + self._trigger_time) + self._state_ts + pending_time + trigger_time) + elif state in SUPPORTED_PENDING_STATES and pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) def _validate_code(self, code, state): """Validate given code.""" - check = self._code is None or code == self._code + if self._code is None: + return True + if isinstance(self._code, str): + alarm_code = self._code + else: + alarm_code = self._code.render(from_state=self._state, + to_state=state) + check = not alarm_code or code == alarm_code if not check: _LOGGER.warning("Invalid code given for %s", state) return check + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + + if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_PRE_PENDING_STATE] = self._previous_state + state_attr[ATTR_POST_PENDING_STATE] = self._state + + return state_attr + def async_added_to_hass(self): - """Subscribe mqtt events. + """Subscribe to MQTT events. This method must be run in the event loop and returns a coroutine. """ @@ -221,6 +349,8 @@ def message_received(topic, payload, qos): self.async_alarm_arm_home(self._code) elif payload == self._payload_arm_away: self.async_alarm_arm_away(self._code) + elif payload == self._payload_arm_night: + self.async_alarm_arm_night(self._code) else: _LOGGER.warning("Received unexpected payload: %s", payload) return @@ -231,5 +361,5 @@ def message_received(topic, payload, qos): @asyncio.coroutine def _async_state_changed_listener(self, entity_id, old_state, new_state): """Publish state change to MQTT.""" - mqtt.async_publish(self.hass, self._state_topic, new_state.state, - self._qos, True) + mqtt.async_publish( + self.hass, self._state_topic, new_state.state, self._qos, True) diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 33bfe464eea06..1422136c40543 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -17,7 +17,9 @@ STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN, CONF_NAME, CONF_CODE) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS) + CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, + MqttAvailability) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -40,7 +42,7 @@ vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -54,15 +56,21 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_PAYLOAD_DISARM), config.get(CONF_PAYLOAD_ARM_HOME), config.get(CONF_PAYLOAD_ARM_AWAY), - config.get(CONF_CODE))]) + config.get(CONF_CODE), + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE))]) -class MqttAlarm(alarm.AlarmControlPanel): +class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel): """Representation of a MQTT alarm status.""" def __init__(self, name, state_topic, command_topic, qos, payload_disarm, - payload_arm_home, payload_arm_away, code): + payload_arm_home, payload_arm_away, code, availability_topic, + payload_available, payload_not_available): """Init the MQTT Alarm Control Panel.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._state = STATE_UNKNOWN self._name = name self._state_topic = state_topic @@ -73,11 +81,11 @@ def __init__(self, name, state_topic, command_topic, qos, payload_disarm, self._payload_arm_away = payload_arm_away self._code = code + @asyncio.coroutine def async_added_to_hass(self): - """Subscribe mqtt events. + """Subscribe mqtt events.""" + yield from super().async_added_to_hass() - This method must be run in the event loop and returns a coroutine. - """ @callback def message_received(topic, payload, qos): """Run when new MQTT message has been received.""" @@ -87,9 +95,9 @@ def message_received(topic, payload, qos): _LOGGER.warning("Received unexpected payload: %s", payload) return self._state = payload - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() - return mqtt.async_subscribe( + yield from mqtt.async_subscribe( self.hass, self._state_topic, message_received, self._qos) @property diff --git a/homeassistant/components/alarm_control_panel/nx584.py b/homeassistant/components/alarm_control_panel/nx584.py index 81a8b02cc64ba..ceb79c1dc7b4f 100644 --- a/homeassistant/components/alarm_control_panel/nx584.py +++ b/homeassistant/components/alarm_control_panel/nx584.py @@ -12,8 +12,8 @@ import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_UNKNOWN, CONF_NAME, CONF_HOST, CONF_PORT) + CONF_HOST, CONF_NAME, CONF_PORT, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pynx584==0.4'] @@ -25,14 +25,14 @@ DEFAULT_PORT = 5007 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): 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, }) def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the nx584 platform.""" + """Set up the NX584 platform.""" name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) @@ -88,7 +88,7 @@ def update(self): self._state = STATE_UNKNOWN zones = [] except IndexError: - _LOGGER.error("nx584 reports no partitions") + _LOGGER.error("NX584 reports no partitions") self._state = STATE_UNKNOWN zones = [] diff --git a/homeassistant/components/alarm_control_panel/satel_integra.py b/homeassistant/components/alarm_control_panel/satel_integra.py new file mode 100644 index 0000000000000..964047f91e965 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/satel_integra.py @@ -0,0 +1,93 @@ +""" +Support for Satel Integra alarm, using ETHM module: https://www.satel.pl/en/ . + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.satel_integra/ +""" +import asyncio +import logging + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.satel_integra import ( + CONF_ARM_HOME_MODE, DATA_SATEL, SIGNAL_PANEL_MESSAGE) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['satel_integra'] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up for Satel Integra alarm panels.""" + if not discovery_info: + return + + device = SatelIntegraAlarmPanel( + "Alarm Panel", discovery_info.get(CONF_ARM_HOME_MODE)) + async_add_devices([device]) + + +class SatelIntegraAlarmPanel(alarm.AlarmControlPanel): + """Representation of an AlarmDecoder-based alarm panel.""" + + def __init__(self, name, arm_home_mode): + """Initialize the alarm panel.""" + self._name = name + self._state = None + self._arm_home_mode = arm_home_mode + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + + @callback + def _message_callback(self, message): + """Handle received messages.""" + if message != self._state: + self._state = message + self.async_schedule_update_ha_state() + else: + _LOGGER.warning("Ignoring alarm status message, same state") + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def code_format(self): + """Return the regex for code format or None if no code is required.""" + return '^\\d{4,6}$' + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @asyncio.coroutine + def async_alarm_disarm(self, code=None): + """Send disarm command.""" + if code: + yield from self.hass.data[DATA_SATEL].disarm(code) + + @asyncio.coroutine + def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + if code: + yield from self.hass.data[DATA_SATEL].arm(code) + + @asyncio.coroutine + def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + if code: + yield from self.hass.data[DATA_SATEL].arm( + code, self._arm_home_mode) diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index 19c3ca0233d44..391de2033c771 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -1,65 +1,81 @@ -alarm_disarm: - description: Send the alarm the command for disarm - - fields: - entity_id: - description: Name of alarm control panel to disarm - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to disarm the alarm control panel with - example: 1234 - -alarm_arm_home: - description: Send the alarm the command for arm home - - fields: - entity_id: - description: Name of alarm control panel to arm home - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to arm home the alarm control panel with - example: 1234 - -alarm_arm_away: - description: Send the alarm the command for arm away - - fields: - entity_id: - description: Name of alarm control panel to arm away - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to arm away the alarm control panel with - example: 1234 - -alarm_arm_night: - description: Send the alarm the command for arm night - - fields: - entity_id: - description: Name of alarm control panel to arm night - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to arm night the alarm control panel with - example: 1234 - -alarm_trigger: - description: Send the alarm the command for trigger - - fields: - entity_id: - description: Name of alarm control panel to trigger - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to trigger the alarm control panel with - example: 1234 - -envisalink_alarm_keypress: - description: Send custom keypresses to the alarm - - fields: - entity_id: - description: Name of the alarm control panel to trigger - example: 'alarm_control_panel.downstairs' - keypress: - description: 'String to send to the alarm panel (1-6 characters)' - example: '*71' +# Describes the format for available alarm control panel services + +alarm_disarm: + description: Send the alarm the command for disarm. + fields: + entity_id: + description: Name of alarm control panel to disarm. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to disarm the alarm control panel with. + example: 1234 + +alarm_arm_home: + description: Send the alarm the command for arm home. + fields: + entity_id: + description: Name of alarm control panel to arm home. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm home the alarm control panel with. + example: 1234 + +alarm_arm_away: + description: Send the alarm the command for arm away. + fields: + entity_id: + description: Name of alarm control panel to arm away. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm away the alarm control panel with. + example: 1234 + +alarm_arm_night: + description: Send the alarm the command for arm night. + fields: + entity_id: + description: Name of alarm control panel to arm night. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm night the alarm control panel with. + example: 1234 + +alarm_trigger: + description: Send the alarm the command for trigger. + fields: + entity_id: + description: Name of alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to trigger the alarm control panel with. + example: 1234 + +envisalink_alarm_keypress: + description: Send custom keypresses to the alarm. + fields: + entity_id: + description: Name of the alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + keypress: + description: 'String to send to the alarm panel (1-6 characters).' + example: '*71' + +alarmdecoder_alarm_toggle_chime: + description: Send the alarm the toggle chime command. + fields: + entity_id: + description: Name of the alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + 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 7f4e4dfa756a4..3b991c5b236b6 100644 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ b/homeassistant/components/alarm_control_panel/simplisafe.py @@ -11,9 +11,9 @@ import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, STATE_UNKNOWN, CONF_CODE, CONF_NAME, - STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, - EVENT_HOMEASSISTANT_STOP) + CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['simplisafe-python==1.0.5'] @@ -22,6 +22,7 @@ DEFAULT_NAME = 'SimpliSafe' DOMAIN = 'simplisafe' + NOTIFICATION_ID = 'simplisafe_notification' NOTIFICATION_TITLE = 'SimpliSafe Setup' @@ -65,7 +66,7 @@ def logout(event): class SimpliSafeAlarm(alarm.AlarmControlPanel): - """Representation a SimpliSafe alarm.""" + """Representation of a SimpliSafe alarm.""" def __init__(self, simplisafe, name, code): """Initialize the SimpliSafe alarm.""" @@ -82,7 +83,7 @@ def name(self): @property def code_format(self): - """One or more characters if code is defined.""" + """Return one or more characters if code is defined.""" return None if self._code is None else '.+' @property @@ -103,12 +104,12 @@ def state(self): def device_state_attributes(self): """Return the state attributes.""" return { - 'temperature': self.simplisafe.temperature(), + 'alarm': self.simplisafe.alarm(), 'co': self.simplisafe.carbon_monoxide(), 'fire': self.simplisafe.fire(), - 'alarm': self.simplisafe.alarm(), + 'flood': self.simplisafe.flood(), 'last_event': self.simplisafe.last_event(), - 'flood': self.simplisafe.flood() + 'temperature': self.simplisafe.temperature(), } def update(self): diff --git a/homeassistant/components/alarm_control_panel/spc.py b/homeassistant/components/alarm_control_panel/spc.py index de4d5098b41ce..5d5b2284bab67 100644 --- a/homeassistant/components/alarm_control_panel/spc.py +++ b/homeassistant/components/alarm_control_panel/spc.py @@ -9,58 +9,65 @@ import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.spc import ( - SpcWebGateway, ATTR_DISCOVER_AREAS, DATA_API, DATA_REGISTRY) + ATTR_DISCOVER_AREAS, DATA_API, DATA_REGISTRY, SpcWebGateway) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN) - _LOGGER = logging.getLogger(__name__) -SPC_AREA_MODE_TO_STATE = {'0': STATE_ALARM_DISARMED, - '1': STATE_ALARM_ARMED_HOME, - '3': STATE_ALARM_ARMED_AWAY} +SPC_AREA_MODE_TO_STATE = { + '0': STATE_ALARM_DISARMED, + '1': STATE_ALARM_ARMED_HOME, + '3': STATE_ALARM_ARMED_AWAY, +} def _get_alarm_state(spc_mode): + """Get the alarm state.""" return SPC_AREA_MODE_TO_STATE.get(spc_mode, STATE_UNKNOWN) @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the SPC alarm control panel platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_AREAS] is None): return - entities = [SpcAlarm(hass=hass, - area_id=area['id'], - name=area['name'], - state=_get_alarm_state(area['mode'])) - for area in discovery_info[ATTR_DISCOVER_AREAS]] + api = hass.data[DATA_API] + devices = [SpcAlarm(api, area) + for area in discovery_info[ATTR_DISCOVER_AREAS]] - async_add_entities(entities) + async_add_devices(devices) class SpcAlarm(alarm.AlarmControlPanel): - """Represents the SPC alarm panel.""" + """Representation of the SPC alarm panel.""" - def __init__(self, hass, area_id, name, state): + def __init__(self, api, area): """Initialize the SPC alarm panel.""" - self._hass = hass - self._area_id = area_id - self._name = name - self._state = state - self._api = hass.data[DATA_API] + self._area_id = area['id'] + self._name = area['name'] + self._state = _get_alarm_state(area['mode']) + if self._state == STATE_ALARM_DISARMED: + self._changed_by = area.get('last_unset_user_name', 'unknown') + else: + self._changed_by = area.get('last_set_user_name', 'unknown') + self._api = api - hass.data[DATA_REGISTRY].register_alarm_device(area_id, self) + @asyncio.coroutine + def async_added_to_hass(self): + """Call for adding new entities.""" + self.hass.data[DATA_REGISTRY].register_alarm_device( + self._area_id, self) @asyncio.coroutine - def async_update_from_spc(self, state): + def async_update_from_spc(self, state, extra): """Update the alarm panel with a new state.""" self._state = state - yield from self.async_update_ha_state() + self._changed_by = extra.get('changed_by', 'unknown') + self.async_schedule_update_ha_state() @property def should_poll(self): @@ -72,6 +79,11 @@ def name(self): """Return the name of the device.""" return self._name + @property + def changed_by(self): + """Return the user the last change was triggered by.""" + return self._changed_by + @property def state(self): """Return the state of the device.""" diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 05dc8aeef202c..1f383e32f925c 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -14,9 +14,11 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, - STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME) + STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME, + STATE_ALARM_ARMED_CUSTOM_BYPASS) -REQUIREMENTS = ['total_connect_client==0.11'] + +REQUIREMENTS = ['total_connect_client==0.17'] _LOGGER = logging.getLogger(__name__) @@ -76,6 +78,8 @@ def update(self): state = STATE_ALARM_ARMED_AWAY elif status == self._client.ARMED_STAY_NIGHT: state = STATE_ALARM_ARMED_NIGHT + elif status == self._client.ARMED_CUSTOM_BYPASS: + state = STATE_ALARM_ARMED_CUSTOM_BYPASS elif status == self._client.ARMING: state = STATE_ALARM_ARMING elif status == self._client.DISARMING: diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py index 66764f58c2695..74d63b1fb9c0f 100644 --- a/homeassistant/components/alarm_control_panel/verisure.py +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -8,8 +8,8 @@ from time import sleep import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.verisure import CONF_ALARM, CONF_CODE_DIGITS from homeassistant.components.verisure import HUB as hub -from homeassistant.components.verisure import (CONF_ALARM, CONF_CODE_DIGITS) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN) @@ -43,7 +43,7 @@ class VerisureAlarm(alarm.AlarmControlPanel): """Representation of a Verisure alarm status.""" def __init__(self): - """Initalize the Verisure alarm panel.""" + """Initialize the Verisure alarm panel.""" self._state = STATE_UNKNOWN self._digits = hub.config.get(CONF_CODE_DIGITS) self._changed_by = None diff --git a/homeassistant/components/alarm_control_panel/wink.py b/homeassistant/components/alarm_control_panel/wink.py index 8bc2539f77256..771d157efe0e0 100644 --- a/homeassistant/components/alarm_control_panel/wink.py +++ b/homeassistant/components/alarm_control_panel/wink.py @@ -8,11 +8,10 @@ import logging import homeassistant.components.alarm_control_panel as alarm -from homeassistant.const import (STATE_UNKNOWN, - STATE_ALARM_DISARMED, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY) -from homeassistant.components.wink import WinkDevice, DOMAIN +from homeassistant.components.wink import DOMAIN, WinkDevice +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_UNKNOWN) _LOGGER = logging.getLogger(__name__) @@ -41,7 +40,7 @@ class WinkCameraDevice(WinkDevice, alarm.AlarmControlPanel): @asyncio.coroutine def async_added_to_hass(self): - """Callback when entity is added to hass.""" + """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['alarm_control_panel'].append(self) @property diff --git a/homeassistant/components/alarmdecoder.py b/homeassistant/components/alarmdecoder.py index 011cc3ad21d61..bc7f1910803bf 100644 --- a/homeassistant/components/alarmdecoder.py +++ b/homeassistant/components/alarmdecoder.py @@ -4,18 +4,18 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/alarmdecoder/ """ -import asyncio import logging +from datetime import timedelta import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.discovery import load_platform +from homeassistant.util import dt as dt_util +from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA -REQUIREMENTS = ['alarmdecoder==0.12.3'] +REQUIREMENTS = ['alarmdecoder==1.13.2'] _LOGGER = logging.getLogger(__name__) @@ -32,6 +32,7 @@ CONF_PANEL_DISPLAY = 'panel_display' CONF_ZONE_NAME = 'name' CONF_ZONE_TYPE = 'type' +CONF_ZONE_RFID = 'rfid' CONF_ZONES = 'zones' DEFAULT_DEVICE_TYPE = 'socket' @@ -51,6 +52,7 @@ SIGNAL_ZONE_FAULT = 'alarmdecoder.zone_fault' SIGNAL_ZONE_RESTORE = 'alarmdecoder.zone_restore' +SIGNAL_RFX_MESSAGE = 'alarmdecoder.rfx_message' DEVICE_SOCKET_SCHEMA = vol.Schema({ vol.Required(CONF_DEVICE_TYPE): 'socket', @@ -67,13 +69,15 @@ ZONE_SCHEMA = vol.Schema({ vol.Required(CONF_ZONE_NAME): cv.string, - vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): cv.string}) + vol.Optional(CONF_ZONE_TYPE, + default=DEFAULT_ZONE_TYPE): vol.Any(DEVICE_CLASSES_SCHEMA), + vol.Optional(CONF_ZONE_RFID): cv.string}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_DEVICE): vol.Any(DEVICE_SOCKET_SCHEMA, - DEVICE_SERIAL_SCHEMA, - DEVICE_USB_SCHEMA), + vol.Required(CONF_DEVICE): vol.Any( + DEVICE_SOCKET_SCHEMA, DEVICE_SERIAL_SCHEMA, + DEVICE_USB_SCHEMA), vol.Optional(CONF_PANEL_DISPLAY, default=DEFAULT_PANEL_DISPLAY): cv.boolean, vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA}, @@ -81,14 +85,14 @@ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +def setup(hass, config): """Set up for the AlarmDecoder devices.""" from alarmdecoder import AlarmDecoder from alarmdecoder.devices import (SocketDevice, SerialDevice, USBDevice) conf = config.get(DOMAIN) + restart = False device = conf.get(CONF_DEVICE) display = conf.get(CONF_PANEL_DISPLAY) zones = conf.get(CONF_ZONES) @@ -99,32 +103,55 @@ def async_setup(hass, config): path = DEFAULT_DEVICE_PATH baud = DEFAULT_DEVICE_BAUD - sync_connect = asyncio.Future(loop=hass.loop) - - def handle_open(device): - """Handle the successful connection.""" - _LOGGER.info("Established a connection with the alarmdecoder") - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - sync_connect.set_result(True) - - @callback def stop_alarmdecoder(event): """Handle the shutdown of AlarmDecoder.""" _LOGGER.debug("Shutting down alarmdecoder") + nonlocal restart + restart = False controller.close() - @callback + def open_connection(now=None): + """Open a connection to AlarmDecoder.""" + from alarmdecoder.util import NoDeviceError + nonlocal restart + try: + controller.open(baud) + except NoDeviceError: + _LOGGER.debug("Failed to connect. Retrying in 5 seconds") + hass.helpers.event.track_point_in_time( + open_connection, dt_util.utcnow() + timedelta(seconds=5)) + return + _LOGGER.debug("Established a connection with the alarmdecoder") + restart = True + + def handle_closed_connection(event): + """Restart after unexpected loss of connection.""" + nonlocal restart + if not restart: + return + restart = False + _LOGGER.warning("AlarmDecoder unexpectedly lost connection.") + hass.add_job(open_connection) + def handle_message(sender, message): """Handle message from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE, message) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_PANEL_MESSAGE, message) + + def handle_rfx_message(sender, message): + """Handle RFX message from AlarmDecoder.""" + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_RFX_MESSAGE, message) def zone_fault_callback(sender, zone): """Handle zone fault from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_ZONE_FAULT, zone) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_ZONE_FAULT, zone) def zone_restore_callback(sender, zone): """Handle zone restore from AlarmDecoder.""" - async_dispatcher_send(hass, SIGNAL_ZONE_RESTORE, zone) + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_ZONE_RESTORE, zone) controller = False if device_type == 'socket': @@ -139,30 +166,25 @@ def zone_restore_callback(sender, zone): AlarmDecoder(USBDevice.find()) return False - controller.on_open += handle_open controller.on_message += handle_message + controller.on_rfx_message += handle_rfx_message controller.on_zone_fault += zone_fault_callback controller.on_zone_restore += zone_restore_callback + controller.on_close += handle_closed_connection hass.data[DATA_AD] = controller - controller.open(baud) - - result = yield from sync_connect + open_connection() - if not result: - return False + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - hass.async_add_job( - async_load_platform(hass, 'alarm_control_panel', DOMAIN, conf, - config)) + load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config) if zones: - hass.async_add_job(async_load_platform( - hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config)) + load_platform( + hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config) if display: - hass.async_add_job(async_load_platform( - hass, 'sensor', DOMAIN, conf, config)) + load_platform(hass, 'sensor', DOMAIN, conf, config) return True diff --git a/homeassistant/components/alert.py b/homeassistant/components/alert.py index 6356f429bed85..9d47e4bd322f1 100644 --- a/homeassistant/components/alert.py +++ b/homeassistant/components/alert.py @@ -7,12 +7,10 @@ import asyncio from datetime import datetime, timedelta import logging -import os import voluptuous as vol from homeassistant.core import callback -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( CONF_ENTITY_ID, STATE_IDLE, CONF_NAME, CONF_STATE, STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_ENTITY_ID) @@ -36,7 +34,7 @@ ALERT_SCHEMA = vol.Schema({ vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_DONE_MESSAGE, default=None): cv.string, + vol.Optional(CONF_DONE_MESSAGE): cv.string, vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_STATE, default=STATE_ON): cv.string, vol.Required(CONF_REPEAT): vol.All(cv.ensure_list, [vol.Coerce(float)]), @@ -123,28 +121,22 @@ def async_handle_alert_service(service_call): # Setup alerts for entity_id, alert in alerts.items(): entity = Alert(hass, entity_id, - alert[CONF_NAME], alert[CONF_DONE_MESSAGE], + alert[CONF_NAME], alert.get(CONF_DONE_MESSAGE), alert[CONF_ENTITY_ID], alert[CONF_STATE], alert[CONF_REPEAT], alert[CONF_SKIP_FIRST], alert[CONF_NOTIFIERS], alert[CONF_CAN_ACK]) all_alerts[entity.entity_id] = entity - # Read descriptions - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - descriptions = descriptions.get(DOMAIN, {}) - # Setup service calls hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_handle_alert_service, - descriptions.get(SERVICE_TURN_OFF), schema=ALERT_SERVICE_SCHEMA) + schema=ALERT_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_handle_alert_service, - descriptions.get(SERVICE_TURN_ON), schema=ALERT_SERVICE_SCHEMA) + schema=ALERT_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TOGGLE, async_handle_alert_service, - descriptions.get(SERVICE_TOGGLE), schema=ALERT_SERVICE_SCHEMA) + schema=ALERT_SERVICE_SCHEMA) tasks = [alert.async_update_ha_state() for alert in all_alerts.values()] if tasks: @@ -285,7 +277,7 @@ def async_turn_off(self, **kwargs): yield from self.async_update_ha_state() @asyncio.coroutine - def async_toggle(self): + def async_toggle(self, **kwargs): """Async toggle alert.""" if self._ack: return self.async_turn_on() diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py deleted file mode 100644 index 25b6537e25583..0000000000000 --- a/homeassistant/components/alexa.py +++ /dev/null @@ -1,320 +0,0 @@ -""" -Support for Alexa skill service end point. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/alexa/ -""" -import asyncio -import copy -import enum -import logging -import uuid -from datetime import datetime - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.const import HTTP_BAD_REQUEST -from homeassistant.helpers import intent, template, config_validation as cv -from homeassistant.components import http - -_LOGGER = logging.getLogger(__name__) - -INTENTS_API_ENDPOINT = '/api/alexa' -FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}' - -CONF_ACTION = 'action' -CONF_CARD = 'card' -CONF_INTENTS = 'intents' -CONF_SPEECH = 'speech' - -CONF_TYPE = 'type' -CONF_TITLE = 'title' -CONF_CONTENT = 'content' -CONF_TEXT = 'text' - -CONF_FLASH_BRIEFINGS = 'flash_briefings' -CONF_UID = 'uid' -CONF_TITLE = 'title' -CONF_AUDIO = 'audio' -CONF_TEXT = 'text' -CONF_DISPLAY_URL = 'display_url' - -ATTR_UID = 'uid' -ATTR_UPDATE_DATE = 'updateDate' -ATTR_TITLE_TEXT = 'titleText' -ATTR_STREAM_URL = 'streamUrl' -ATTR_MAIN_TEXT = 'mainText' -ATTR_REDIRECTION_URL = 'redirectionURL' - -DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' - -DOMAIN = 'alexa' -DEPENDENCIES = ['http'] - - -class SpeechType(enum.Enum): - """The Alexa speech types.""" - - plaintext = "PlainText" - ssml = "SSML" - - -SPEECH_MAPPINGS = { - 'plain': SpeechType.plaintext, - 'ssml': SpeechType.ssml, -} - - -class CardType(enum.Enum): - """The Alexa card types.""" - - simple = "Simple" - link_account = "LinkAccount" - - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: { - CONF_FLASH_BRIEFINGS: { - cv.string: vol.All(cv.ensure_list, [{ - vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string, - vol.Required(CONF_TITLE): cv.template, - vol.Optional(CONF_AUDIO): cv.template, - vol.Required(CONF_TEXT, default=""): cv.template, - vol.Optional(CONF_DISPLAY_URL): cv.template, - }]), - } - } -}, extra=vol.ALLOW_EXTRA) - - -@asyncio.coroutine -def async_setup(hass, config): - """Activate Alexa component.""" - flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {}) - - hass.http.register_view(AlexaIntentsView) - hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings)) - - return True - - -class AlexaIntentsView(http.HomeAssistantView): - """Handle Alexa requests.""" - - url = INTENTS_API_ENDPOINT - name = 'api:alexa' - - @asyncio.coroutine - def post(self, request): - """Handle Alexa.""" - hass = request.app['hass'] - data = yield from request.json() - - _LOGGER.debug('Received Alexa request: %s', data) - - req = data.get('request') - - if req is None: - _LOGGER.error('Received invalid data from Alexa: %s', data) - return self.json_message('Expected request value not received', - HTTP_BAD_REQUEST) - - req_type = req['type'] - - if req_type == 'SessionEndedRequest': - return None - - alexa_intent_info = req.get('intent') - alexa_response = AlexaResponse(hass, alexa_intent_info) - - if req_type != 'IntentRequest' and req_type != 'LaunchRequest': - _LOGGER.warning('Received unsupported request: %s', req_type) - return self.json_message( - 'Received unsupported request: {}'.format(req_type), - HTTP_BAD_REQUEST) - - if req_type == 'LaunchRequest': - intent_name = data.get('session', {}) \ - .get('application', {}) \ - .get('applicationId') - else: - intent_name = alexa_intent_info['name'] - - try: - intent_response = yield from intent.async_handle( - hass, DOMAIN, intent_name, - {key: {'value': value} for key, value - in alexa_response.variables.items()}) - except intent.UnknownIntent as err: - _LOGGER.warning('Received unknown intent %s', intent_name) - alexa_response.add_speech( - SpeechType.plaintext, - "This intent is not yet configured within Home Assistant.") - return self.json(alexa_response) - - except intent.InvalidSlotInfo as err: - _LOGGER.error('Received invalid slot data from Alexa: %s', err) - return self.json_message('Invalid slot data received', - HTTP_BAD_REQUEST) - except intent.IntentError: - _LOGGER.exception('Error handling request for %s', intent_name) - return self.json_message('Error handling intent', HTTP_BAD_REQUEST) - - for intent_speech, alexa_speech in SPEECH_MAPPINGS.items(): - if intent_speech in intent_response.speech: - alexa_response.add_speech( - alexa_speech, - intent_response.speech[intent_speech]['speech']) - break - - if 'simple' in intent_response.card: - alexa_response.add_card( - CardType.simple, intent_response.card['simple']['title'], - intent_response.card['simple']['content']) - - return self.json(alexa_response) - - -class AlexaResponse(object): - """Help generating the response for Alexa.""" - - def __init__(self, hass, intent_info): - """Initialize the response.""" - self.hass = hass - self.speech = None - self.card = None - self.reprompt = None - self.session_attributes = {} - self.should_end_session = True - self.variables = {} - # Intent is None if request was a LaunchRequest or SessionEndedRequest - if intent_info is not None: - for key, value in intent_info.get('slots', {}).items(): - if 'value' in value: - underscored_key = key.replace('.', '_') - self.variables[underscored_key] = value['value'] - - def add_card(self, card_type, title, content): - """Add a card to the response.""" - assert self.card is None - - card = { - "type": card_type.value - } - - if card_type == CardType.link_account: - self.card = card - return - - card["title"] = title - card["content"] = content - self.card = card - - def add_speech(self, speech_type, text): - """Add speech to the response.""" - assert self.speech is None - - key = 'ssml' if speech_type == SpeechType.ssml else 'text' - - self.speech = { - 'type': speech_type.value, - key: text - } - - def add_reprompt(self, speech_type, text): - """Add reprompt if user does not answer.""" - assert self.reprompt is None - - key = 'ssml' if speech_type == SpeechType.ssml else 'text' - - self.reprompt = { - 'type': speech_type.value, - key: text.async_render(self.variables) - } - - def as_dict(self): - """Return response in an Alexa valid dict.""" - response = { - 'shouldEndSession': self.should_end_session - } - - if self.card is not None: - response['card'] = self.card - - if self.speech is not None: - response['outputSpeech'] = self.speech - - if self.reprompt is not None: - response['reprompt'] = { - 'outputSpeech': self.reprompt - } - - return { - 'version': '1.0', - 'sessionAttributes': self.session_attributes, - 'response': response, - } - - -class AlexaFlashBriefingView(http.HomeAssistantView): - """Handle Alexa Flash Briefing skill requests.""" - - url = FLASH_BRIEFINGS_API_ENDPOINT - name = 'api:alexa:flash_briefings' - - def __init__(self, hass, flash_briefings): - """Initialize Alexa view.""" - super().__init__() - self.flash_briefings = copy.deepcopy(flash_briefings) - template.attach(hass, self.flash_briefings) - - @callback - def get(self, request, briefing_id): - """Handle Alexa Flash Briefing request.""" - _LOGGER.debug('Received Alexa flash briefing request for: %s', - briefing_id) - - if self.flash_briefings.get(briefing_id) is None: - err = 'No configured Alexa flash briefing was found for: %s' - _LOGGER.error(err, briefing_id) - return b'', 404 - - briefing = [] - - for item in self.flash_briefings.get(briefing_id, []): - output = {} - if item.get(CONF_TITLE) is not None: - if isinstance(item.get(CONF_TITLE), template.Template): - output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() - else: - output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) - - if item.get(CONF_TEXT) is not None: - if isinstance(item.get(CONF_TEXT), template.Template): - output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() - else: - output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) - - if item.get(CONF_UID) is not None: - output[ATTR_UID] = item.get(CONF_UID) - - if item.get(CONF_AUDIO) is not None: - if isinstance(item.get(CONF_AUDIO), template.Template): - output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() - else: - output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) - - if item.get(CONF_DISPLAY_URL) is not None: - if isinstance(item.get(CONF_DISPLAY_URL), - template.Template): - output[ATTR_REDIRECTION_URL] = \ - item[CONF_DISPLAY_URL].async_render() - else: - output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) - - output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) - - briefing.append(output) - - return self.json(briefing) diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py new file mode 100644 index 0000000000000..d120270650fac --- /dev/null +++ b/homeassistant/components/alexa/__init__.py @@ -0,0 +1,75 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import entityfilter + +from . import flash_briefings, intent, smart_home +from .const import ( + CONF_AUDIO, CONF_DISPLAY_URL, CONF_TEXT, CONF_TITLE, CONF_UID, DOMAIN, + CONF_FILTER, CONF_ENTITY_CONFIG) + +_LOGGER = logging.getLogger(__name__) + +CONF_FLASH_BRIEFINGS = 'flash_briefings' +CONF_SMART_HOME = 'smart_home' + +DEPENDENCIES = ['http'] + +ALEXA_ENTITY_SCHEMA = vol.Schema({ + vol.Optional(smart_home.CONF_DESCRIPTION): cv.string, + vol.Optional(smart_home.CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(smart_home.CONF_NAME): cv.string, +}) + +SMART_HOME_SCHEMA = vol.Schema({ + vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA} +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + CONF_FLASH_BRIEFINGS: { + cv.string: vol.All(cv.ensure_list, [{ + vol.Optional(CONF_UID): cv.string, + vol.Required(CONF_TITLE): cv.template, + vol.Optional(CONF_AUDIO): cv.template, + vol.Required(CONF_TEXT, default=""): cv.template, + vol.Optional(CONF_DISPLAY_URL): cv.template, + }]), + }, + # vol.Optional here would mean we couldn't distinguish between an empty + # smart_home: and none at all. + CONF_SMART_HOME: vol.Any(SMART_HOME_SCHEMA, None), + } +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Activate Alexa component.""" + config = config.get(DOMAIN, {}) + flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS) + + intent.async_setup(hass) + + if flash_briefings_config: + flash_briefings.async_setup(hass, flash_briefings_config) + + try: + smart_home_config = config[CONF_SMART_HOME] + except KeyError: + pass + else: + smart_home_config = smart_home_config or SMART_HOME_SCHEMA({}) + smart_home.async_setup(hass, smart_home_config) + + return True diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py new file mode 100644 index 0000000000000..7d6489b535aab --- /dev/null +++ b/homeassistant/components/alexa/const.py @@ -0,0 +1,23 @@ +"""Constants for the Alexa integration.""" +DOMAIN = 'alexa' + +# Flash briefing constants +CONF_UID = 'uid' +CONF_TITLE = 'title' +CONF_AUDIO = 'audio' +CONF_TEXT = 'text' +CONF_DISPLAY_URL = 'display_url' + +CONF_FILTER = 'filter' +CONF_ENTITY_CONFIG = 'entity_config' + +ATTR_UID = 'uid' +ATTR_UPDATE_DATE = 'updateDate' +ATTR_TITLE_TEXT = 'titleText' +ATTR_STREAM_URL = 'streamUrl' +ATTR_MAIN_TEXT = 'mainText' +ATTR_REDIRECTION_URL = 'redirectionURL' + +SYN_RESOLUTION_MATCH = 'ER_SUCCESS_MATCH' + +DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py new file mode 100644 index 0000000000000..02f47b0561794 --- /dev/null +++ b/homeassistant/components/alexa/flash_briefings.py @@ -0,0 +1,95 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import copy +from datetime import datetime +import logging +import uuid + +from homeassistant.components import http +from homeassistant.core import callback +from homeassistant.helpers import template + +from .const import ( + ATTR_MAIN_TEXT, ATTR_REDIRECTION_URL, ATTR_STREAM_URL, ATTR_TITLE_TEXT, + ATTR_UID, ATTR_UPDATE_DATE, CONF_AUDIO, CONF_DISPLAY_URL, CONF_TEXT, + CONF_TITLE, CONF_UID, DATE_FORMAT) + +_LOGGER = logging.getLogger(__name__) + +FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}' + + +@callback +def async_setup(hass, flash_briefing_config): + """Activate Alexa component.""" + hass.http.register_view( + AlexaFlashBriefingView(hass, flash_briefing_config)) + + +class AlexaFlashBriefingView(http.HomeAssistantView): + """Handle Alexa Flash Briefing skill requests.""" + + url = FLASH_BRIEFINGS_API_ENDPOINT + name = 'api:alexa:flash_briefings' + + def __init__(self, hass, flash_briefings): + """Initialize Alexa view.""" + super().__init__() + self.flash_briefings = copy.deepcopy(flash_briefings) + template.attach(hass, self.flash_briefings) + + @callback + def get(self, request, briefing_id): + """Handle Alexa Flash Briefing request.""" + _LOGGER.debug("Received Alexa flash briefing request for: %s", + briefing_id) + + if self.flash_briefings.get(briefing_id) is None: + err = "No configured Alexa flash briefing was found for: %s" + _LOGGER.error(err, briefing_id) + return b'', 404 + + briefing = [] + + for item in self.flash_briefings.get(briefing_id, []): + output = {} + if item.get(CONF_TITLE) is not None: + if isinstance(item.get(CONF_TITLE), template.Template): + output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() + else: + output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) + + if item.get(CONF_TEXT) is not None: + if isinstance(item.get(CONF_TEXT), template.Template): + output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() + else: + output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) + + uid = item.get(CONF_UID) + if uid is None: + uid = str(uuid.uuid4()) + output[ATTR_UID] = uid + + if item.get(CONF_AUDIO) is not None: + if isinstance(item.get(CONF_AUDIO), template.Template): + output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() + else: + output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) + + if item.get(CONF_DISPLAY_URL) is not None: + if isinstance(item.get(CONF_DISPLAY_URL), + template.Template): + output[ATTR_REDIRECTION_URL] = \ + item[CONF_DISPLAY_URL].async_render() + else: + output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) + + output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) + + briefing.append(output) + + return self.json(briefing) diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py new file mode 100644 index 0000000000000..b6d406bd550f3 --- /dev/null +++ b/homeassistant/components/alexa/intent.py @@ -0,0 +1,296 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import asyncio +import enum +import logging + +from homeassistant.components import http +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent +from homeassistant.util.decorator import Registry + +from .const import DOMAIN, SYN_RESOLUTION_MATCH + +_LOGGER = logging.getLogger(__name__) + +HANDLERS = Registry() + +INTENTS_API_ENDPOINT = '/api/alexa' + + +class SpeechType(enum.Enum): + """The Alexa speech types.""" + + plaintext = 'PlainText' + ssml = 'SSML' + + +SPEECH_MAPPINGS = { + 'plain': SpeechType.plaintext, + 'ssml': SpeechType.ssml, +} + + +class CardType(enum.Enum): + """The Alexa card types.""" + + simple = 'Simple' + link_account = 'LinkAccount' + + +@callback +def async_setup(hass): + """Activate Alexa component.""" + hass.http.register_view(AlexaIntentsView) + + +class UnknownRequest(HomeAssistantError): + """When an unknown Alexa request is passed in.""" + + +class AlexaIntentsView(http.HomeAssistantView): + """Handle Alexa requests.""" + + url = INTENTS_API_ENDPOINT + name = 'api:alexa' + + @asyncio.coroutine + def post(self, request): + """Handle Alexa.""" + hass = request.app['hass'] + message = yield from request.json() + + _LOGGER.debug("Received Alexa request: %s", message) + + try: + response = yield from async_handle_message(hass, message) + return b'' if response is None else self.json(response) + except UnknownRequest as err: + _LOGGER.warning(str(err)) + return self.json(intent_error_response( + hass, message, str(err))) + + except intent.UnknownIntent as err: + _LOGGER.warning(str(err)) + return self.json(intent_error_response( + hass, message, + "This intent is not yet configured within Home Assistant.")) + + except intent.InvalidSlotInfo as err: + _LOGGER.error("Received invalid slot data from Alexa: %s", err) + return self.json(intent_error_response( + hass, message, + "Invalid slot information received for this intent.")) + + except intent.IntentError as err: + _LOGGER.exception(str(err)) + return self.json(intent_error_response( + hass, message, "Error handling intent.")) + + +def intent_error_response(hass, message, error): + """Return an Alexa response that will speak the error message.""" + alexa_intent_info = message.get('request').get('intent') + alexa_response = AlexaResponse(hass, alexa_intent_info) + alexa_response.add_speech(SpeechType.plaintext, error) + return alexa_response.as_dict() + + +@asyncio.coroutine +def async_handle_message(hass, message): + """Handle an Alexa intent. + + Raises: + - UnknownRequest + - intent.UnknownIntent + - intent.InvalidSlotInfo + - intent.IntentError + + """ + req = message.get('request') + req_type = req['type'] + + handler = HANDLERS.get(req_type) + + if not handler: + raise UnknownRequest('Received unknown request {}'.format(req_type)) + + return (yield from handler(hass, message)) + + +@HANDLERS.register('SessionEndedRequest') +@asyncio.coroutine +def async_handle_session_end(hass, message): + """Handle a session end request.""" + return None + + +@HANDLERS.register('IntentRequest') +@HANDLERS.register('LaunchRequest') +@asyncio.coroutine +def async_handle_intent(hass, message): + """Handle an intent request. + + Raises: + - intent.UnknownIntent + - intent.InvalidSlotInfo + - intent.IntentError + + """ + req = message.get('request') + alexa_intent_info = req.get('intent') + alexa_response = AlexaResponse(hass, alexa_intent_info) + + if req['type'] == 'LaunchRequest': + intent_name = message.get('session', {}) \ + .get('application', {}) \ + .get('applicationId') + else: + intent_name = alexa_intent_info['name'] + + intent_response = yield from intent.async_handle( + hass, DOMAIN, intent_name, + {key: {'value': value} for key, value + in alexa_response.variables.items()}) + + for intent_speech, alexa_speech in SPEECH_MAPPINGS.items(): + if intent_speech in intent_response.speech: + alexa_response.add_speech( + alexa_speech, + intent_response.speech[intent_speech]['speech']) + break + + if 'simple' in intent_response.card: + alexa_response.add_card( + CardType.simple, intent_response.card['simple']['title'], + intent_response.card['simple']['content']) + + return alexa_response.as_dict() + + +def resolve_slot_synonyms(key, request): + """Check slot request for synonym resolutions.""" + # Default to the spoken slot value if more than one or none are found. For + # reference to the request object structure, see the Alexa docs: + # https://tinyurl.com/ybvm7jhs + resolved_value = request['value'] + + if ('resolutions' in request and + 'resolutionsPerAuthority' in request['resolutions'] and + len(request['resolutions']['resolutionsPerAuthority']) >= 1): + + # Extract all of the possible values from each authority with a + # successful match + possible_values = [] + + for entry in request['resolutions']['resolutionsPerAuthority']: + if entry['status']['code'] != SYN_RESOLUTION_MATCH: + continue + + possible_values.extend([item['value']['name'] + for item + in entry['values']]) + + # If there is only one match use the resolved value, otherwise the + # resolution cannot be determined, so use the spoken slot value + if len(possible_values) == 1: + resolved_value = possible_values[0] + else: + _LOGGER.debug( + 'Found multiple synonym resolutions for slot value: {%s: %s}', + key, + request['value'] + ) + + return resolved_value + + +class AlexaResponse(object): + """Help generating the response for Alexa.""" + + def __init__(self, hass, intent_info): + """Initialize the response.""" + self.hass = hass + self.speech = None + self.card = None + self.reprompt = None + self.session_attributes = {} + self.should_end_session = True + self.variables = {} + + # Intent is None if request was a LaunchRequest or SessionEndedRequest + if intent_info is not None: + for key, value in intent_info.get('slots', {}).items(): + # Only include slots with values + if 'value' not in value: + continue + + _key = key.replace('.', '_') + + self.variables[_key] = resolve_slot_synonyms(key, value) + + def add_card(self, card_type, title, content): + """Add a card to the response.""" + assert self.card is None + + card = { + "type": card_type.value + } + + if card_type == CardType.link_account: + self.card = card + return + + card["title"] = title + card["content"] = content + self.card = card + + def add_speech(self, speech_type, text): + """Add speech to the response.""" + assert self.speech is None + + key = 'ssml' if speech_type == SpeechType.ssml else 'text' + + self.speech = { + 'type': speech_type.value, + key: text + } + + def add_reprompt(self, speech_type, text): + """Add reprompt if user does not answer.""" + assert self.reprompt is None + + key = 'ssml' if speech_type == SpeechType.ssml else 'text' + + self.reprompt = { + 'type': speech_type.value, + key: text.async_render(self.variables) + } + + def as_dict(self): + """Return response in an Alexa valid dict.""" + response = { + 'shouldEndSession': self.should_end_session + } + + if self.card is not None: + response['card'] = self.card + + if self.speech is not None: + response['outputSpeech'] = self.speech + + if self.reprompt is not None: + response['reprompt'] = { + 'outputSpeech': self.reprompt + } + + return { + 'version': '1.0', + 'sessionAttributes': self.session_attributes, + 'response': response, + } diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py new file mode 100644 index 0000000000000..c5c68f1af40fa --- /dev/null +++ b/homeassistant/components/alexa/smart_home.py @@ -0,0 +1,1519 @@ +"""Support for alexa Smart Home Skill API.""" +import asyncio +import logging +import math +from datetime import datetime +from uuid import uuid4 + +from homeassistant.components import ( + 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, 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__) + +API_DIRECTIVE = 'directive' +API_ENDPOINT = 'endpoint' +API_EVENT = 'event' +API_CONTEXT = 'context' +API_HEADER = 'header' +API_PAYLOAD = 'payload' + +API_TEMP_UNITS = { + TEMP_FAHRENHEIT: 'FAHRENHEIT', + 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' +CONF_DISPLAY_CATEGORIES = 'display_categories' + +HANDLERS = Registry() +ENTITY_ADAPTERS = Registry() + + +class _DisplayCategory(object): + """Possible display categories for Discovery response. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories + """ + + # Describes a combination of devices set to a specific state, when the + # state change must occur in a specific order. For example, a "watch + # Netflix" scene might require the: 1. TV to be powered on & 2. Input set + # to HDMI1. Applies to Scenes + ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER" + + # Indicates media devices with video or photo capabilities. + CAMERA = "CAMERA" + + # Indicates a door. + DOOR = "DOOR" + + # Indicates light sources or fixtures. + LIGHT = "LIGHT" + + # An endpoint that cannot be described in on of the other categories. + OTHER = "OTHER" + + # Describes a combination of devices set to a specific state, when the + # order of the state change is not important. For example a bedtime scene + # might include turning off lights and lowering the thermostat, but the + # order is unimportant. Applies to Scenes + SCENE_TRIGGER = "SCENE_TRIGGER" + + # Indicates an endpoint that locks. + SMARTLOCK = "SMARTLOCK" + + # Indicates modules that are plugged into an existing electrical outlet. + # Can control a variety of devices. + SMARTPLUG = "SMARTPLUG" + + # Indicates the endpoint is a speaker or speaker system. + SPEAKER = "SPEAKER" + + # Indicates in-wall switches wired to the electrical system. Can control a + # variety of devices. + SWITCH = "SWITCH" + + # Indicates endpoints that report the temperature only. + TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR" + + # Indicates endpoints that control temperature, stand-alone air + # conditioners, or heaters with direct temperature control. + THERMOSTAT = "THERMOSTAT" + + # Indicates the endpoint is a television. + # pylint: disable=invalid-name + TV = "TV" + + +def _capability(interface, + version=3, + supports_deactivation=None, + retrievable=None, + properties_supported=None, + cap_type='AlexaInterface'): + """Return a Smart Home API capability object. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#capability-object + + There are some additional fields allowed but not implemented here since + we've no use case for them yet: + + - proactively_reported + + `supports_deactivation` applies only to scenes. + """ + result = { + 'type': cap_type, + 'interface': interface, + 'version': version, + } + + if supports_deactivation is not None: + result['supportsDeactivation'] = supports_deactivation + + if retrievable is not None: + result['retrievable'] = retrievable + + if properties_supported is not None: + result['properties'] = {'supported': properties_supported} + + return result + + +class _UnsupportedInterface(Exception): + """This entity does not support the requested Smart Home API interface.""" + + +class _UnsupportedProperty(Exception): + """This entity does not support the requested Smart Home API property.""" + + +class _AlexaEntity(object): + """An adaptation of an entity, expressed in Alexa's terms. + + The API handlers should manipulate entities only through this interface. + """ + + def __init__(self, config, entity): + self.config = config + self.entity = entity + self.entity_conf = config.entity_config.get(entity.entity_id, {}) + + def friendly_name(self): + """Return the Alexa API friendly name.""" + return self.entity_conf.get(CONF_NAME, self.entity.name) + + def description(self): + """Return the Alexa API description.""" + return self.entity_conf.get(CONF_DESCRIPTION, self.entity.entity_id) + + def entity_id(self): + """Return the Alexa API entity id.""" + return self.entity.entity_id.replace('.', '#') + + def display_categories(self): + """Return a list of display categories.""" + entity_conf = self.config.entity_config.get(self.entity.entity_id, {}) + if CONF_DISPLAY_CATEGORIES in entity_conf: + return [entity_conf[CONF_DISPLAY_CATEGORIES]] + return self.default_display_categories() + + def default_display_categories(self): + """Return a list of default display categories. + + This can be overridden by the user in the Home Assistant configuration. + + See also _DisplayCategory. + """ + raise NotImplementedError + + def get_interface(self, capability): + """Return the given _AlexaInterface. + + Raises _UnsupportedInterface. + """ + pass + + def interfaces(self): + """Return a list of supported interfaces. + + Used for discovery. The list should contain _AlexaInterface instances. + If the list is empty, this entity will not be discovered. + """ + raise NotImplementedError + + +class _AlexaInterface(object): + def __init__(self, entity): + self.entity = entity + + def name(self): + """Return the Alexa API name of this interface.""" + raise NotImplementedError + + @staticmethod + def properties_supported(): + """Return what properties this entity supports.""" + return [] + + @staticmethod + def properties_proactively_reported(): + """Return True if properties asynchronously reported.""" + return False + + @staticmethod + def properties_retrievable(): + """Return True if properties can be retrieved.""" + return False + + @staticmethod + def get_property(name): + """Read and return a property. + + Return value should be a dict, or raise _UnsupportedProperty. + + Properties can also have a timeOfSample and uncertaintyInMilliseconds, + but returning those metadata is not yet implemented. + """ + raise _UnsupportedProperty(name) + + @staticmethod + def supports_deactivation(): + """Applicable only to scenes.""" + return None + + def serialize_discovery(self): + """Serialize according to the Discovery API.""" + result = { + 'type': 'AlexaInterface', + 'interface': self.name(), + 'version': '3', + 'properties': { + 'supported': self.properties_supported(), + 'proactivelyReported': self.properties_proactively_reported(), + 'retrievable': self.properties_retrievable(), + }, + } + + # pylint: disable=assignment-from-none + supports_deactivation = self.supports_deactivation() + if supports_deactivation is not None: + result['supportsDeactivation'] = supports_deactivation + return result + + def serialize_properties(self): + """Return properties serialized for an API response.""" + for prop in self.properties_supported(): + prop_name = prop['name'] + yield { + 'name': prop_name, + 'namespace': self.name(), + 'value': self.get_property(prop_name), + } + + +class _AlexaPowerController(_AlexaInterface): + def name(self): + return 'Alexa.PowerController' + + def properties_supported(self): + return [{'name': 'powerState'}] + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'powerState': + raise _UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return 'ON' + return 'OFF' + + +class _AlexaLockController(_AlexaInterface): + def name(self): + return 'Alexa.LockController' + + def properties_supported(self): + return [{'name': 'lockState'}] + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'lockState': + raise _UnsupportedProperty(name) + + if self.entity.state == STATE_LOCKED: + return 'LOCKED' + elif self.entity.state == STATE_UNLOCKED: + return 'UNLOCKED' + return 'JAMMED' + + +class _AlexaSceneController(_AlexaInterface): + def __init__(self, entity, supports_deactivation): + _AlexaInterface.__init__(self, entity) + self.supports_deactivation = lambda: supports_deactivation + + def name(self): + return 'Alexa.SceneController' + + +class _AlexaBrightnessController(_AlexaInterface): + def name(self): + return 'Alexa.BrightnessController' + + def properties_supported(self): + return [{'name': 'brightness'}] + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'brightness': + raise _UnsupportedProperty(name) + if 'brightness' in self.entity.attributes: + return round(self.entity.attributes['brightness'] / 255.0 * 100) + return 0 + + +class _AlexaColorController(_AlexaInterface): + def name(self): + return 'Alexa.ColorController' + + +class _AlexaColorTemperatureController(_AlexaInterface): + def name(self): + return 'Alexa.ColorTemperatureController' + + +class _AlexaPercentageController(_AlexaInterface): + def name(self): + return 'Alexa.PercentageController' + + +class _AlexaSpeaker(_AlexaInterface): + def name(self): + return 'Alexa.Speaker' + + +class _AlexaStepSpeaker(_AlexaInterface): + def name(self): + return 'Alexa.StepSpeaker' + + +class _AlexaPlaybackController(_AlexaInterface): + def name(self): + return 'Alexa.PlaybackController' + + +class _AlexaInputController(_AlexaInterface): + def name(self): + return 'Alexa.InputController' + + +class _AlexaTemperatureSensor(_AlexaInterface): + def name(self): + return 'Alexa.TemperatureSensor' + + def properties_supported(self): + return [{'name': 'temperature'}] + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'temperature': + 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(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], + } + + +@ENTITY_ADAPTERS.register(alert.DOMAIN) +@ENTITY_ADAPTERS.register(automation.DOMAIN) +@ENTITY_ADAPTERS.register(group.DOMAIN) +@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) +class _GenericCapabilities(_AlexaEntity): + """A generic, on/off device. + + The choice of last resort. + """ + + def default_display_categories(self): + return [_DisplayCategory.OTHER] + + def interfaces(self): + return [_AlexaPowerController(self.entity)] + + +@ENTITY_ADAPTERS.register(switch.DOMAIN) +class _SwitchCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.SWITCH] + + def interfaces(self): + 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): + return [_DisplayCategory.DOOR] + + def interfaces(self): + yield _AlexaPowerController(self.entity) + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & cover.SUPPORT_SET_POSITION: + yield _AlexaPercentageController(self.entity) + + +@ENTITY_ADAPTERS.register(light.DOMAIN) +class _LightCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.LIGHT] + + def interfaces(self): + yield _AlexaPowerController(self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & light.SUPPORT_BRIGHTNESS: + yield _AlexaBrightnessController(self.entity) + if supported & light.SUPPORT_COLOR: + yield _AlexaColorController(self.entity) + if supported & light.SUPPORT_COLOR_TEMP: + yield _AlexaColorTemperatureController(self.entity) + + +@ENTITY_ADAPTERS.register(fan.DOMAIN) +class _FanCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.OTHER] + + def interfaces(self): + yield _AlexaPowerController(self.entity) + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & fan.SUPPORT_SET_SPEED: + yield _AlexaPercentageController(self.entity) + + +@ENTITY_ADAPTERS.register(lock.DOMAIN) +class _LockCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.SMARTLOCK] + + def interfaces(self): + return [_AlexaLockController(self.entity)] + + +@ENTITY_ADAPTERS.register(media_player.DOMAIN) +class _MediaPlayerCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.TV] + + def interfaces(self): + yield _AlexaPowerController(self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & media_player.SUPPORT_VOLUME_SET: + yield _AlexaSpeaker(self.entity) + + step_volume_features = (media_player.SUPPORT_VOLUME_MUTE | + media_player.SUPPORT_VOLUME_STEP) + if supported & step_volume_features: + yield _AlexaStepSpeaker(self.entity) + + playback_features = (media_player.SUPPORT_PLAY | + media_player.SUPPORT_PAUSE | + media_player.SUPPORT_STOP | + media_player.SUPPORT_NEXT_TRACK | + media_player.SUPPORT_PREVIOUS_TRACK) + if supported & playback_features: + yield _AlexaPlaybackController(self.entity) + + if supported & media_player.SUPPORT_SELECT_SOURCE: + yield _AlexaInputController(self.entity) + + +@ENTITY_ADAPTERS.register(scene.DOMAIN) +class _SceneCapabilities(_AlexaEntity): + def description(self): + # Required description as per Amazon Scene docs + scene_fmt = '{} (Scene connected via Home Assistant)' + return scene_fmt.format(_AlexaEntity.description(self)) + + def default_display_categories(self): + return [_DisplayCategory.SCENE_TRIGGER] + + def interfaces(self): + return [_AlexaSceneController(self.entity, + supports_deactivation=False)] + + +@ENTITY_ADAPTERS.register(script.DOMAIN) +class _ScriptCapabilities(_AlexaEntity): + def default_display_categories(self): + return [_DisplayCategory.ACTIVITY_TRIGGER] + + def interfaces(self): + can_cancel = bool(self.entity.attributes.get('can_cancel')) + return [_AlexaSceneController(self.entity, + supports_deactivation=can_cancel)] + + +@ENTITY_ADAPTERS.register(sensor.DOMAIN) +class _SensorCapabilities(_AlexaEntity): + def default_display_categories(self): + # although there are other kinds of sensors, all but temperature + # sensors are currently ignored. + return [_DisplayCategory.TEMPERATURE_SENSOR] + + def interfaces(self): + attrs = self.entity.attributes + if attrs.get(CONF_UNIT_OF_MEASUREMENT) in ( + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + ): + yield _AlexaTemperatureSensor(self.entity) + + +class _Cause(object): + """Possible causes for property changes. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#cause-object + """ + + # Indicates that the event was caused by a customer interaction with an + # application. For example, a customer switches on a light, or locks a door + # using the Alexa app or an app provided by a device vendor. + APP_INTERACTION = 'APP_INTERACTION' + + # Indicates that the event was caused by a physical interaction with an + # endpoint. For example manually switching on a light or manually locking a + # door lock + PHYSICAL_INTERACTION = 'PHYSICAL_INTERACTION' + + # Indicates that the event was caused by the periodic poll of an appliance, + # which found a change in value. For example, you might poll a temperature + # sensor every hour, and send the updated temperature to Alexa. + PERIODIC_POLL = 'PERIODIC_POLL' + + # Indicates that the event was caused by the application of a device rule. + # For example, a customer configures a rule to switch on a light if a + # motion sensor detects motion. In this case, Alexa receives an event from + # the motion sensor, and another event from the light to indicate that its + # state change was caused by the rule. + RULE_TRIGGER = 'RULE_TRIGGER' + + # Indicates that the event was caused by a voice interaction with Alexa. + # For example a user speaking to their Echo device. + VOICE_INTERACTION = 'VOICE_INTERACTION' + + +class Config: + """Hold the configuration for Alexa.""" + + def __init__(self, should_expose, entity_config=None): + """Initialize the configuration.""" + self.should_expose = should_expose + self.entity_config = entity_config or {} + + +@ha.callback +def async_setup(hass, config): + """Activate Smart Home functionality of Alexa component. + + This is optional, triggered by having a `smart_home:` sub-section in the + alexa configuration. + + Even if that's disabled, the functionality in this module may still be used + by the cloud component which will call async_handle_message directly. + """ + smart_home_config = Config( + should_expose=config[CONF_FILTER], + entity_config=config.get(CONF_ENTITY_CONFIG), + ) + hass.http.register_view(SmartHomeView(smart_home_config)) + + +class SmartHomeView(http.HomeAssistantView): + """Expose Smart Home v3 payload interface via HTTP POST.""" + + url = SMART_HOME_HTTP_ENDPOINT + name = 'api:alexa:smart_home' + + def __init__(self, smart_home_config): + """Initialize.""" + self.smart_home_config = smart_home_config + + @asyncio.coroutine + def post(self, request): + """Handle Alexa Smart Home requests. + + The Smart Home API requires the endpoint to be implemented in AWS + Lambda, which will need to forward the requests to here and pass back + the response. + """ + hass = request.app['hass'] + message = yield from request.json() + + _LOGGER.debug("Received Alexa Smart Home request: %s", message) + + response = yield from async_handle_message( + hass, self.smart_home_config, message) + _LOGGER.debug("Sending Alexa Smart Home response: %s", response) + return b'' if response is None else self.json(response) + + +@asyncio.coroutine +def async_handle_message(hass, config, message): + """Handle incoming API messages.""" + assert message[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3' + + # Read head data + message = message[API_DIRECTIVE] + namespace = message[API_HEADER]['namespace'] + name = message[API_HEADER]['name'] + + # Do we support this API request? + funct_ref = HANDLERS.get((namespace, name)) + if not funct_ref: + _LOGGER.warning( + "Unsupported API request %s/%s", namespace, name) + return api_error(message) + + return (yield from funct_ref(hass, config, message)) + + +def api_message(request, + name='Response', + namespace='Alexa', + payload=None, + context=None): + """Create a API formatted response message. + + Async friendly. + """ + payload = payload or {} + + response = { + API_EVENT: { + API_HEADER: { + 'namespace': namespace, + 'name': name, + 'messageId': str(uuid4()), + 'payloadVersion': '3', + }, + API_PAYLOAD: payload, + } + } + + # If a correlation token exists, add it to header / Need by Async requests + token = request[API_HEADER].get('correlationToken') + if token: + response[API_EVENT][API_HEADER]['correlationToken'] = token + + # Extend event with endpoint object / Need by Async requests + if API_ENDPOINT in request: + response[API_EVENT][API_ENDPOINT] = request[API_ENDPOINT].copy() + + if context is not None: + response[API_CONTEXT] = context + + return response + + +def api_error(request, + namespace='Alexa', + error_type='INTERNAL_ERROR', + error_message="", + payload=None): + """Create a API formatted error response. + + Async friendly. + """ + payload = payload or {} + payload['type'] = error_type + payload['message'] = error_message + + _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')) +@asyncio.coroutine +def async_api_discovery(hass, config, request): + """Create a API formatted discovery response. + + Async friendly. + """ + discovery_endpoints = [] + + for entity in hass.states.async_all(): + if not config.should_expose(entity.entity_id): + _LOGGER.debug("Not exposing %s because filtered by config", + entity.entity_id) + continue + + if entity.domain not in ENTITY_ADAPTERS: + continue + alexa_entity = ENTITY_ADAPTERS[entity.domain](config, entity) + + endpoint = { + 'displayCategories': alexa_entity.display_categories(), + 'additionalApplianceDetails': {}, + 'endpointId': alexa_entity.entity_id(), + 'friendlyName': alexa_entity.friendly_name(), + 'description': alexa_entity.description(), + 'manufacturerName': 'Home Assistant', + } + + endpoint['capabilities'] = [ + i.serialize_discovery() for i in alexa_entity.interfaces()] + + if not endpoint['capabilities']: + _LOGGER.debug("Not exposing %s because it has no capabilities", + entity.entity_id) + continue + discovery_endpoints.append(endpoint) + + return api_message( + request, name='Discover.Response', namespace='Alexa.Discovery', + payload={'endpoints': discovery_endpoints}) + + +def extract_entity(funct): + """Decorate for extract entity object from request.""" + @asyncio.coroutine + def async_api_entity_wrapper(hass, config, request): + """Process a turn on request.""" + entity_id = request[API_ENDPOINT]['endpointId'].replace('#', '.') + + # extract state object + entity = hass.states.get(entity_id) + if not entity: + _LOGGER.error("Can't process %s for %s", + request[API_HEADER]['name'], entity_id) + return api_error(request, error_type='NO_SUCH_ENDPOINT') + + return (yield from funct(hass, config, request, entity)) + + return async_api_entity_wrapper + + +@HANDLERS.register(('Alexa.PowerController', 'TurnOn')) +@extract_entity +@asyncio.coroutine +def async_api_turn_on(hass, config, request, entity): + """Process a turn on request.""" + domain = entity.domain + if entity.domain == group.DOMAIN: + domain = ha.DOMAIN + + service = SERVICE_TURN_ON + if entity.domain == cover.DOMAIN: + service = cover.SERVICE_OPEN_COVER + + yield from hass.services.async_call(domain, service, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PowerController', 'TurnOff')) +@extract_entity +@asyncio.coroutine +def async_api_turn_off(hass, config, request, entity): + """Process a turn off request.""" + domain = entity.domain + if entity.domain == group.DOMAIN: + domain = ha.DOMAIN + + service = SERVICE_TURN_OFF + if entity.domain == cover.DOMAIN: + service = cover.SERVICE_CLOSE_COVER + + yield from hass.services.async_call(domain, service, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) +@extract_entity +@asyncio.coroutine +def async_api_set_brightness(hass, config, request, entity): + """Process a set brightness request.""" + brightness = int(request[API_PAYLOAD]['brightness']) + + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS_PCT: brightness, + }, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness')) +@extract_entity +@asyncio.coroutine +def async_api_adjust_brightness(hass, config, request, entity): + """Process an adjust brightness request.""" + brightness_delta = int(request[API_PAYLOAD]['brightnessDelta']) + + # read current state + try: + current = math.floor( + int(entity.attributes.get(light.ATTR_BRIGHTNESS)) / 255 * 100) + except ZeroDivisionError: + current = 0 + + # set brightness + brightness = max(0, brightness_delta + current) + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS_PCT: brightness, + }, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.ColorController', 'SetColor')) +@extract_entity +@asyncio.coroutine +def async_api_set_color(hass, config, request, entity): + """Process a set color request.""" + 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']) + ) + + 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) + + +@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature')) +@extract_entity +@asyncio.coroutine +def async_api_set_color_temperature(hass, config, request, entity): + """Process a set color temperature request.""" + kelvin = int(request[API_PAYLOAD]['colorTemperatureInKelvin']) + + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_KELVIN: kelvin, + }, blocking=False) + + return api_message(request) + + +@HANDLERS.register( + ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature')) +@extract_entity +@asyncio.coroutine +def async_api_decrease_color_temp(hass, config, request, entity): + """Process a decrease color temperature request.""" + current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) + max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS)) + + value = min(max_mireds, current + 50) + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_COLOR_TEMP: value, + }, blocking=False) + + return api_message(request) + + +@HANDLERS.register( + ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature')) +@extract_entity +@asyncio.coroutine +def async_api_increase_color_temp(hass, config, request, entity): + """Process an increase color temperature request.""" + current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) + min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS)) + + value = max(min_mireds, current - 50) + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_COLOR_TEMP: value, + }, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.SceneController', 'Activate')) +@extract_entity +@asyncio.coroutine +def async_api_activate(hass, config, request, entity): + """Process an activate request.""" + domain = entity.domain + + yield from hass.services.async_call(domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False) + + payload = { + 'cause': {'type': _Cause.VOICE_INTERACTION}, + 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) + } + + return api_message( + request, + name='ActivationStarted', + namespace='Alexa.SceneController', + payload=payload, + ) + + +@HANDLERS.register(('Alexa.SceneController', 'Deactivate')) +@extract_entity +@asyncio.coroutine +def async_api_deactivate(hass, config, request, entity): + """Process a deactivate request.""" + domain = entity.domain + + yield from hass.services.async_call(domain, SERVICE_TURN_OFF, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False) + + payload = { + 'cause': {'type': _Cause.VOICE_INTERACTION}, + 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),) + } + + return api_message( + request, + name='DeactivationStarted', + namespace='Alexa.SceneController', + payload=payload, + ) + + +@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage')) +@extract_entity +@asyncio.coroutine +def async_api_set_percentage(hass, config, request, entity): + """Process a set percentage request.""" + percentage = int(request[API_PAYLOAD]['percentage']) + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == fan.DOMAIN: + service = fan.SERVICE_SET_SPEED + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + elif percentage <= 100: + speed = "high" + data[fan.ATTR_SPEED] = speed + + elif entity.domain == cover.DOMAIN: + service = SERVICE_SET_COVER_POSITION + data[cover.ATTR_POSITION] = percentage + + yield from hass.services.async_call( + entity.domain, service, data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage')) +@extract_entity +@asyncio.coroutine +def async_api_adjust_percentage(hass, config, request, entity): + """Process an adjust percentage request.""" + percentage_delta = int(request[API_PAYLOAD]['percentageDelta']) + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == fan.DOMAIN: + service = fan.SERVICE_SET_SPEED + speed = entity.attributes.get(fan.ATTR_SPEED) + + if speed == "off": + current = 0 + elif speed == "low": + current = 33 + elif speed == "medium": + current = 66 + elif speed == "high": + current = 100 + + # set percentage + percentage = max(0, percentage_delta + current) + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + elif percentage <= 100: + speed = "high" + + data[fan.ATTR_SPEED] = speed + + elif entity.domain == cover.DOMAIN: + service = SERVICE_SET_COVER_POSITION + + current = entity.attributes.get(cover.ATTR_POSITION) + + data[cover.ATTR_POSITION] = max(0, percentage_delta + current) + + yield from hass.services.async_call( + entity.domain, service, data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.LockController', 'Lock')) +@extract_entity +@asyncio.coroutine +def async_api_lock(hass, config, request, entity): + """Process a lock request.""" + yield from hass.services.async_call(entity.domain, SERVICE_LOCK, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False) + + # Alexa expects a lockState in the response, we don't know the actual + # lockState at this point but assume it is locked. It is reported + # correctly later when ReportState is called. The alt. to this approach + # is to implement DeferredResponse + properties = [{ + 'name': 'lockState', + 'namespace': 'Alexa.LockController', + 'value': 'LOCKED' + }] + return api_message(request, context={'properties': properties}) + + +# Not supported by Alexa yet +@HANDLERS.register(('Alexa.LockController', 'Unlock')) +@extract_entity +@asyncio.coroutine +def async_api_unlock(hass, config, request, entity): + """Process an unlock request.""" + yield from hass.services.async_call(entity.domain, SERVICE_UNLOCK, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.Speaker', 'SetVolume')) +@extract_entity +@asyncio.coroutine +def async_api_set_volume(hass, config, request, entity): + """Process a set volume request.""" + volume = round(float(request[API_PAYLOAD]['volume'] / 100), 2) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + yield from hass.services.async_call( + entity.domain, SERVICE_VOLUME_SET, + data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.InputController', 'SelectInput')) +@extract_entity +@asyncio.coroutine +def async_api_select_input(hass, config, request, entity): + """Process a set input request.""" + media_input = request[API_PAYLOAD]['input'] + + # attempt to map the ALL UPPERCASE payload name to a source + source_list = entity.attributes[media_player.ATTR_INPUT_SOURCE_LIST] or [] + for source in source_list: + # response will always be space separated, so format the source in the + # most likely way to find a match + formatted_source = source.lower().replace('-', ' ').replace('_', ' ') + if formatted_source in media_input.lower(): + media_input = source + break + else: + msg = 'failed to map input {} to a media source on {}'.format( + media_input, entity.entity_id) + return api_error( + request, error_type='INVALID_VALUE', error_message=msg) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_INPUT_SOURCE: media_input, + } + + yield from hass.services.async_call( + entity.domain, media_player.SERVICE_SELECT_SOURCE, + data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume')) +@extract_entity +@asyncio.coroutine +def async_api_adjust_volume(hass, config, request, entity): + """Process an adjust volume request.""" + volume_delta = int(request[API_PAYLOAD]['volume']) + + current_level = entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) + + # read current state + try: + current = math.floor(int(current_level * 100)) + except ZeroDivisionError: + current = 0 + + volume = float(max(0, volume_delta + current) / 100) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + yield from hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_SET, + data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume')) +@extract_entity +@asyncio.coroutine +def async_api_adjust_volume_step(hass, config, request, entity): + """Process an adjust volume step request.""" + # media_player volume up/down service does not support specifying steps + # each component handles it differently e.g. via config. + # For now we use the volumeSteps returned to figure out if we + # should step up/down + volume_step = request[API_PAYLOAD]['volumeSteps'] + + data = { + ATTR_ENTITY_ID: entity.entity_id, + } + + if volume_step > 0: + yield from hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_UP, + data, blocking=False) + elif volume_step < 0: + yield from hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_DOWN, + data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.StepSpeaker', 'SetMute')) +@HANDLERS.register(('Alexa.Speaker', 'SetMute')) +@extract_entity +@asyncio.coroutine +def async_api_set_mute(hass, config, request, entity): + """Process a set mute request.""" + mute = bool(request[API_PAYLOAD]['mute']) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_MUTED: mute, + } + + yield from hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_MUTE, + data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Play')) +@extract_entity +@asyncio.coroutine +def async_api_play(hass, config, request, entity): + """Process a play request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call( + entity.domain, SERVICE_MEDIA_PLAY, + data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Pause')) +@extract_entity +@asyncio.coroutine +def async_api_pause(hass, config, request, entity): + """Process a pause request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call( + entity.domain, SERVICE_MEDIA_PAUSE, + data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Stop')) +@extract_entity +@asyncio.coroutine +def async_api_stop(hass, config, request, entity): + """Process a stop request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call( + entity.domain, SERVICE_MEDIA_STOP, + data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Next')) +@extract_entity +@asyncio.coroutine +def async_api_next(hass, config, request, entity): + """Process a next request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call( + entity.domain, SERVICE_MEDIA_NEXT_TRACK, + data, blocking=False) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Previous')) +@extract_entity +@asyncio.coroutine +def async_api_previous(hass, config, request, entity): + """Process a previous request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call( + entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK, + data, blocking=False) + + 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 +def async_api_reportstate(hass, config, request, entity): + """Process a ReportState request.""" + alexa_entity = ENTITY_ADAPTERS[entity.domain](config, entity) + properties = [] + for interface in alexa_entity.interfaces(): + properties.extend(interface.serialize_properties()) + + return api_message( + request, + name='StateReport', + context={'properties': properties} + ) diff --git a/homeassistant/components/amcrest.py b/homeassistant/components/amcrest.py index 157b9574a0647..d0e470e3f8ec4 100644 --- a/homeassistant/components/amcrest.py +++ b/homeassistant/components/amcrest.py @@ -10,14 +10,15 @@ 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 @@ '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, @@ -79,8 +86,10 @@ vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, - vol.Optional(CONF_SENSORS, default=None): + 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) @@ -89,17 +98,19 @@ def setup(hass, config): """Set up the Amcrest IP Camera component.""" from amcrest import AmcrestCamera + hass.data[DATA_AMCREST] = {} 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: {}
    ' @@ -107,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) @@ -126,22 +138,41 @@ def setup(hass, config): else: authentication = None + hass.data[DATA_AMCREST][name] = AmcrestDevice( + camera, name, authentication, ffmpeg_arguments, stream_source, + resolution) + discovery.load_platform( hass, 'camera', DOMAIN, { - 'device': camera, - CONF_AUTHENTICATION: authentication, - CONF_FFMPEG_ARGUMENTS: ffmpeg_arguments, CONF_NAME: name, - CONF_RESOLUTION: resolution, - CONF_STREAM_SOURCE: stream_source, }, config) if sensors: discovery.load_platform( hass, 'sensor', DOMAIN, { - 'device': camera, CONF_NAME: name, CONF_SENSORS: sensors, }, config) + if switches: + discovery.load_platform( + hass, 'switch', DOMAIN, { + CONF_NAME: name, + CONF_SWITCHES: switches + }, config) + return True + + +class AmcrestDevice(object): + """Representation of a base Amcrest discovery device.""" + + def __init__(self, camera, name, authentication, ffmpeg_arguments, + stream_source, resolution): + """Initialize the entity.""" + self.device = camera + self.name = name + self.authentication = authentication + self.ffmpeg_arguments = ffmpeg_arguments + self.stream_source = stream_source + self.resolution = resolution diff --git a/homeassistant/components/android_ip_webcam.py b/homeassistant/components/android_ip_webcam.py index 2fb039f0ab3c6..13fa64438d378 100644 --- a/homeassistant/components/android_ip_webcam.py +++ b/homeassistant/components/android_ip_webcam.py @@ -140,11 +140,11 @@ cv.time_period, vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, - vol.Optional(CONF_SWITCHES, default=None): + vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [vol.In(SWITCHES)]), - vol.Optional(CONF_SENSORS, default=None): + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)]), - vol.Optional(CONF_MOTION_SENSOR, default=None): cv.boolean, + vol.Optional(CONF_MOTION_SENSOR): cv.boolean, })]) }, extra=vol.ALLOW_EXTRA) @@ -165,9 +165,9 @@ def async_setup_ipcamera(cam_config): password = cam_config.get(CONF_PASSWORD) name = cam_config[CONF_NAME] interval = cam_config[CONF_SCAN_INTERVAL] - switches = cam_config[CONF_SWITCHES] - sensors = cam_config[CONF_SENSORS] - motion = cam_config[CONF_MOTION_SENSOR] + switches = cam_config.get(CONF_SWITCHES) + sensors = cam_config.get(CONF_SENSORS) + motion = cam_config.get(CONF_MOTION_SENSOR) # Init ip webcam cam = PyDroidIPCam( @@ -251,7 +251,7 @@ class AndroidIPCamEntity(Entity): """The Android device running IP Webcam.""" def __init__(self, host, ipcam): - """Initialize the data oject.""" + """Initialize the data object.""" self._host = host self._ipcam = ipcam @@ -263,7 +263,7 @@ def async_ipcam_update(host): """Update callback.""" if self._host != host: return - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update) diff --git a/homeassistant/components/apcupsd.py b/homeassistant/components/apcupsd.py index dd29e7d602f5f..7e2b4cda28f82 100644 --- a/homeassistant/components/apcupsd.py +++ b/homeassistant/components/apcupsd.py @@ -66,7 +66,7 @@ class APCUPSdData(object): """ def __init__(self, host, port): - """Initialize the data oject.""" + """Initialize the data object.""" from apcaccess import status self._host = host self._port = port diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index c22683970bf90..dc34006ad0367 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -13,7 +13,7 @@ import homeassistant.core as ha import homeassistant.remote as rem -from homeassistant.bootstrap import ERROR_LOG_FILENAME +from homeassistant.bootstrap import DATA_LOGGING from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND, @@ -24,6 +24,7 @@ __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 @@ -51,8 +52,8 @@ def setup(hass, config): hass.http.register_view(APIComponentsView) hass.http.register_view(APITemplateView) - hass.http.register_static_path( - URL_API_ERROR_LOG, hass.config.path(ERROR_LOG_FILENAME), False) + if DATA_LOGGING in hass.data: + hass.http.register_view(APIErrorLog) return True @@ -75,8 +76,7 @@ class APIEventStream(HomeAssistantView): url = URL_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'] @@ -87,8 +87,7 @@ def get(self, request): 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 @@ -103,11 +102,11 @@ def forward_events(event): 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) @@ -115,13 +114,13 @@ def forward_events(event): _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 @@ -129,10 +128,9 @@ def forward_events(event): msg = "data: {}\n\n".format(payload) _LOGGER.debug('STREAM %s WRITING %s', id(stop_obj), msg.strip()) - response.write(msg.encode("UTF-8")) - yield from response.drain() + 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)) @@ -200,12 +198,11 @@ def get(self, request, entity_id): return self.json(state) 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) @@ -257,11 +254,14 @@ class APIEventView(HomeAssistantView): url = '/api/events/{event_type}' 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() - event_data = json.loads(body) if body else None + 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) if event_data is not None and not isinstance(event_data, dict): return self.json_message('Event data should be a JSON object', @@ -288,10 +288,10 @@ class APIServicesView(HomeAssistantView): url = URL_API_SERVICES name = "api:services" - @ha.callback - def get(self, request): + async def get(self, request): """Get registered services.""" - return self.json(async_services_json(request.app['hass'])) + services = await async_services_json(request.app['hass']) + return self.json(services) class APIDomainServicesView(HomeAssistantView): @@ -300,18 +300,21 @@ class APIDomainServicesView(HomeAssistantView): 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() - data = json.loads(body) if body else None + 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) 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) @@ -334,11 +337,10 @@ class APITemplateView(HomeAssistantView): url = URL_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: @@ -346,10 +348,23 @@ def post(self, request): HTTP_BAD_REQUEST) -def async_services_json(hass): +class APIErrorLog(HomeAssistantView): + """View to fetch the 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 = await async_get_all_descriptions(hass) return [{"domain": key, "services": value} - for key, value in hass.services.async_services().items()] + for key, value in descriptions.items()] def async_events_json(hass): diff --git a/homeassistant/components/apiai.py b/homeassistant/components/apiai.py deleted file mode 100644 index eb6cd0027f7e8..0000000000000 --- a/homeassistant/components/apiai.py +++ /dev/null @@ -1,133 +0,0 @@ -""" -Support for API.AI webhook. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/apiai/ -""" -import asyncio -import logging - -import voluptuous as vol - -from homeassistant.const import PROJECT_NAME, HTTP_BAD_REQUEST -from homeassistant.helpers import intent, template -from homeassistant.components.http import HomeAssistantView - -_LOGGER = logging.getLogger(__name__) - -INTENTS_API_ENDPOINT = '/api/apiai' - -CONF_INTENTS = 'intents' -CONF_SPEECH = 'speech' -CONF_ACTION = 'action' -CONF_ASYNC_ACTION = 'async_action' - -DEFAULT_CONF_ASYNC_ACTION = False - -DOMAIN = 'apiai' -DEPENDENCIES = ['http'] - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: {} -}, extra=vol.ALLOW_EXTRA) - - -@asyncio.coroutine -def async_setup(hass, config): - """Activate API.AI component.""" - hass.http.register_view(ApiaiIntentsView) - - return True - - -class ApiaiIntentsView(HomeAssistantView): - """Handle API.AI requests.""" - - url = INTENTS_API_ENDPOINT - name = 'api:apiai' - - @asyncio.coroutine - def post(self, request): - """Handle API.AI.""" - hass = request.app['hass'] - data = yield from request.json() - - _LOGGER.debug("Received api.ai request: %s", data) - - req = data.get('result') - - if req is None: - _LOGGER.error("Received invalid data from api.ai: %s", data) - return self.json_message( - "Expected result value not received", HTTP_BAD_REQUEST) - - action_incomplete = req['actionIncomplete'] - - if action_incomplete: - return None - - action = req.get('action') - parameters = req.get('parameters') - apiai_response = ApiaiResponse(parameters) - - if action == "": - _LOGGER.warning("Received intent with empty action") - apiai_response.add_speech( - "You have not defined an action in your api.ai intent.") - return self.json(apiai_response) - - try: - intent_response = yield from intent.async_handle( - hass, DOMAIN, action, - {key: {'value': value} for key, value - in parameters.items()}) - - except intent.UnknownIntent as err: - _LOGGER.warning('Received unknown intent %s', action) - apiai_response.add_speech( - "This intent is not yet configured within Home Assistant.") - return self.json(apiai_response) - - except intent.InvalidSlotInfo as err: - _LOGGER.error('Received invalid slot data: %s', err) - return self.json_message('Invalid slot data received', - HTTP_BAD_REQUEST) - except intent.IntentError: - _LOGGER.exception('Error handling request for %s', action) - return self.json_message('Error handling intent', HTTP_BAD_REQUEST) - - if 'plain' in intent_response.speech: - apiai_response.add_speech( - intent_response.speech['plain']['speech']) - - return self.json(apiai_response) - - -class ApiaiResponse(object): - """Help generating the response for API.AI.""" - - def __init__(self, parameters): - """Initialize the response.""" - self.speech = None - self.parameters = {} - # Parameter names replace '.' and '-' for '_' - for key, value in parameters.items(): - underscored_key = key.replace('.', '_').replace('-', '_') - self.parameters[underscored_key] = value - - def add_speech(self, text): - """Add speech to the response.""" - assert self.speech is None - - if isinstance(text, template.Template): - text = text.async_render(self.parameters) - - self.speech = text - - def as_dict(self): - """Return response in an API.AI valid dict.""" - return { - 'speech': self.speech, - 'displayText': self.speech, - 'source': PROJECT_NAME, - } diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 7a2ff7610f7f5..a9bd5c9c8bcfc 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -4,20 +4,20 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/apple_tv/ """ -import os import asyncio import logging +from typing import Sequence, TypeVar, Union + import voluptuous as vol -from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_ENTITY_ID) -from homeassistant.config import load_yaml_config_file -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME +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.4'] +REQUIREMENTS = ['pyatv==0.3.9'] _LOGGER = logging.getLogger(__name__) @@ -45,13 +45,24 @@ NOTIFICATION_SCAN_ID = 'apple_tv_scan_notification' NOTIFICATION_SCAN_TITLE = 'Apple TV Scan' +T = TypeVar('T') + + +# This version of ensure_list interprets an empty dict as no value +def ensure_list(value: Union[T, Sequence[T]]) -> Sequence[T]: + """Wrap value in list if it is not one.""" + if value is None or (isinstance(value, dict) and not value): + return [] + return value if isinstance(value, list) else [value] + + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + DOMAIN: vol.All(ensure_list, [vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_LOGIN_ID): cv.string, + vol.Optional(CONF_CREDENTIALS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_CREDENTIALS, default=None): cv.string, - vol.Optional(CONF_START_OFF, default=False): cv.boolean + vol.Optional(CONF_START_OFF, default=False): cv.boolean, })]) }, extra=vol.ALLOW_EXTRA) @@ -130,9 +141,13 @@ def async_setup(hass, config): @asyncio.coroutine def async_service_handler(service): - """Handler for service calls.""" + """Handle service calls.""" entity_ids = service.data.get(ATTR_ENTITY_ID) + if service.service == SERVICE_SCAN: + hass.async_add_job(scan_for_apple_tvs, hass) + return + if entity_ids: devices = [device for device in hass.data[DATA_ENTITIES] if device.entity_id in entity_ids] @@ -140,20 +155,20 @@ def async_service_handler(service): devices = hass.data[DATA_ENTITIES] for device in devices: + if service.service != SERVICE_AUTHENTICATE: + continue + atv = device.atv - if service.service == SERVICE_AUTHENTICATE: - credentials = yield from atv.airplay.generate_credentials() - yield from atv.airplay.load_credentials(credentials) - _LOGGER.debug('Generated new credentials: %s', credentials) - yield from atv.airplay.start_authentication() - hass.async_add_job(request_configuration, - hass, config, atv, credentials) - elif service.service == SERVICE_SCAN: - hass.async_add_job(scan_for_apple_tvs, hass) + credentials = yield from atv.airplay.generate_credentials() + yield from atv.airplay.load_credentials(credentials) + _LOGGER.debug('Generated new credentials: %s', credentials) + yield from atv.airplay.start_authentication() + hass.async_add_job(request_configuration, + hass, config, atv, credentials) @asyncio.coroutine def atv_discovered(service, info): - """Setup an Apple TV that was auto discovered.""" + """Set up an Apple TV that was auto discovered.""" yield from _setup_atv(hass, { CONF_NAME: info['name'], CONF_HOST: info['host'], @@ -167,18 +182,12 @@ def atv_discovered(service, info): if tasks: yield from asyncio.wait(tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_SCAN, async_service_handler, - descriptions.get(SERVICE_SCAN), schema=APPLE_TV_SCAN_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_AUTHENTICATE, async_service_handler, - descriptions.get(SERVICE_AUTHENTICATE), schema=APPLE_TV_AUTHENTICATE_SCHEMA) return True @@ -186,7 +195,7 @@ def atv_discovered(service, info): @asyncio.coroutine def _setup_atv(hass, atv_config): - """Setup an Apple TV.""" + """Set up an Apple TV.""" import pyatv name = atv_config.get(CONF_NAME) host = atv_config.get(CONF_HOST) @@ -237,7 +246,7 @@ def init(self): @property def turned_on(self): - """If device is on or off.""" + """Return true if device is on or off.""" return self._is_on def set_power_on(self, value): diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index 1ba2acb4fe073..7e51ec8c045e1 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -1,5 +1,5 @@ """ -This component provides basic support for Netgear Arlo IP cameras. +This component provides support for Netgear Arlo IP cameras. For more details about this component, please refer to the documentation at https://home-assistant.io/components/arlo/ @@ -12,7 +12,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -REQUIREMENTS = ['pyarlo==0.0.4'] +REQUIREMENTS = ['pyarlo==0.1.2'] _LOGGER = logging.getLogger(__name__) @@ -23,7 +23,7 @@ DOMAIN = 'arlo' NOTIFICATION_ID = 'arlo_notification' -NOTIFICATION_TITLE = 'Arlo Camera Setup' +NOTIFICATION_TITLE = 'Arlo Component Setup' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -47,7 +47,7 @@ def setup(hass, config): return False hass.data[DATA_ARLO] = arlo except (ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect to Netgar Arlo: %s", str(ex)) + _LOGGER.error("Unable to connect to Netgear Arlo: %s", str(ex)) hass.components.persistent_notification.create( 'Error: {}
    ' 'You will need to restart hass after fixing.' diff --git a/homeassistant/components/asterisk_mbox.py b/homeassistant/components/asterisk_mbox.py index c1dafb87a6d57..0b5e7c1e1d783 100644 --- a/homeassistant/components/asterisk_mbox.py +++ b/homeassistant/components/asterisk_mbox.py @@ -1,34 +1,34 @@ -"""Support for Asterisk Voicemail interface.""" +""" +Support for Asterisk Voicemail interface. +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/asterisk_mbox/ +""" import logging import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import discovery -from homeassistant.const import (CONF_HOST, - CONF_PORT, CONF_PASSWORD) - +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback -from homeassistant.helpers.dispatcher import (async_dispatcher_connect, - async_dispatcher_send) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) REQUIREMENTS = ['asterisk_mbox==0.4.0'] -SIGNAL_MESSAGE_UPDATE = 'asterisk_mbox.message_updated' -SIGNAL_MESSAGE_REQUEST = 'asterisk_mbox.message_request' +_LOGGER = logging.getLogger(__name__) DOMAIN = 'asterisk_mbox' -_LOGGER = logging.getLogger(__name__) - +SIGNAL_MESSAGE_REQUEST = 'asterisk_mbox.message_request' +SIGNAL_MESSAGE_UPDATE = 'asterisk_mbox.message_updated' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT): int, vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_PORT): int, }), }, extra=vol.ALLOW_EXTRA) @@ -43,7 +43,7 @@ def setup(hass, config): hass.data[DOMAIN] = AsteriskData(hass, host, port, password) - discovery.load_platform(hass, "mailbox", DOMAIN, {}, config) + discovery.load_platform(hass, 'mailbox', DOMAIN, {}, config) return True @@ -68,15 +68,14 @@ def handle_data(self, command, msg): from asterisk_mbox.commands import CMD_MESSAGE_LIST if command == CMD_MESSAGE_LIST: - _LOGGER.info("AsteriskVM sent updated message list") - self.messages = sorted(msg, - key=lambda item: item['info']['origtime'], - reverse=True) - async_dispatcher_send(self.hass, SIGNAL_MESSAGE_UPDATE, - self.messages) + _LOGGER.debug("AsteriskVM sent updated message list") + self.messages = sorted( + msg, key=lambda item: item['info']['origtime'], reverse=True) + async_dispatcher_send( + self.hass, SIGNAL_MESSAGE_UPDATE, self.messages) @callback def _request_messages(self): """Handle changes to the mailbox.""" - _LOGGER.info("Requesting message list") + _LOGGER.debug("Requesting message list") self.client.messages() diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py new file mode 100644 index 0000000000000..2a7da86c6cf01 --- /dev/null +++ b/homeassistant/components/august.py @@ -0,0 +1,257 @@ +""" +Support for August devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/august/ +""" + +import logging +from datetime import timedelta + +import voluptuous as vol +from requests import RequestException + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT) +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +_CONFIGURING = {} + +REQUIREMENTS = ['py-august==0.4.0'] + +DEFAULT_TIMEOUT = 10 +ACTIVITY_FETCH_LIMIT = 10 +ACTIVITY_INITIAL_FETCH_LIMIT = 20 + +CONF_LOGIN_METHOD = 'login_method' +CONF_INSTALL_ID = 'install_id' + +NOTIFICATION_ID = 'august_notification' +NOTIFICATION_TITLE = "August Setup" + +AUGUST_CONFIG_FILE = '.august.conf' + +DATA_AUGUST = 'august' +DOMAIN = 'august' +DEFAULT_ENTITY_NAMESPACE = 'august' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) +DEFAULT_SCAN_INTERVAL = timedelta(seconds=5) +LOGIN_METHODS = ['phone', 'email'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_LOGIN_METHOD): vol.In(LOGIN_METHODS), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_INSTALL_ID): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + +AUGUST_COMPONENTS = [ + 'camera', 'binary_sensor', 'lock' +] + + +def request_configuration(hass, config, api, authenticator): + """Request configuration steps from the user.""" + configurator = hass.components.configurator + + def august_configuration_callback(data): + """Run when the configuration callback is called.""" + from august.authenticator import ValidationResult + + result = authenticator.validate_verification_code( + data.get('verification_code')) + + if result == ValidationResult.INVALID_VERIFICATION_CODE: + configurator.notify_errors(_CONFIGURING[DOMAIN], + "Invalid verification code") + elif result == ValidationResult.VALIDATED: + setup_august(hass, config, api, authenticator) + + if DOMAIN not in _CONFIGURING: + authenticator.send_verification_code() + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + login_method = conf.get(CONF_LOGIN_METHOD) + + _CONFIGURING[DOMAIN] = configurator.request_config( + NOTIFICATION_TITLE, + august_configuration_callback, + description="Please check your {} ({}) and enter the verification " + "code below".format(login_method, username), + submit_caption='Verify', + fields=[{ + 'id': 'verification_code', + 'name': "Verification code", + 'type': 'string'}] + ) + + +def setup_august(hass, config, api, authenticator): + """Set up the August component.""" + from august.authenticator import AuthenticationState + + authentication = None + try: + authentication = authenticator.authenticate() + except RequestException as ex: + _LOGGER.error("Unable to connect to August 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) + + state = authentication.state + + if state == AuthenticationState.AUTHENTICATED: + if DOMAIN in _CONFIGURING: + hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN)) + + hass.data[DATA_AUGUST] = AugustData(api, authentication.access_token) + + for component in AUGUST_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + elif state == AuthenticationState.BAD_PASSWORD: + return False + elif state == AuthenticationState.REQUIRES_VALIDATION: + request_configuration(hass, config, api, authenticator) + return True + + return False + + +def setup(hass, config): + """Set up the August component.""" + from august.api import Api + from august.authenticator import Authenticator + + conf = config[DOMAIN] + api = Api(timeout=conf.get(CONF_TIMEOUT)) + + authenticator = Authenticator( + api, + conf.get(CONF_LOGIN_METHOD), + conf.get(CONF_USERNAME), + conf.get(CONF_PASSWORD), + install_id=conf.get(CONF_INSTALL_ID), + access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE)) + + return setup_august(hass, config, api, authenticator) + + +class AugustData: + """August data object.""" + + def __init__(self, api, access_token): + """Init August data object.""" + self._api = api + self._access_token = access_token + self._doorbells = self._api.get_doorbells(self._access_token) or [] + self._locks = self._api.get_operable_locks(self._access_token) or [] + self._house_ids = [d.house_id for d in self._doorbells + self._locks] + + self._doorbell_detail_by_id = {} + self._lock_status_by_id = {} + self._lock_detail_by_id = {} + self._activities_by_id = {} + + @property + def house_ids(self): + """Return a list of house_ids.""" + return self._house_ids + + @property + def doorbells(self): + """Return a list of doorbells.""" + return self._doorbells + + @property + def locks(self): + """Return a list of locks.""" + return self._locks + + def get_device_activities(self, device_id, *activity_types): + """Return a list of activities.""" + self._update_device_activities() + + activities = self._activities_by_id.get(device_id, []) + if activity_types: + return [a for a in activities if a.activity_type in activity_types] + return activities + + def get_latest_device_activity(self, device_id, *activity_types): + """Return latest activity.""" + activities = self.get_device_activities(device_id, *activity_types) + return next(iter(activities or []), None) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): + """Update data object with latest from August API.""" + for house_id in self.house_ids: + activities = self._api.get_house_activities(self._access_token, + house_id, + limit=limit) + + device_ids = {a.device_id for a in activities} + for device_id in device_ids: + self._activities_by_id[device_id] = [a for a in activities if + a.device_id == device_id] + + def get_doorbell_detail(self, doorbell_id): + """Return doorbell detail.""" + self._update_doorbells() + return self._doorbell_detail_by_id.get(doorbell_id) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_doorbells(self): + detail_by_id = {} + + for doorbell in self._doorbells: + detail_by_id[doorbell.device_id] = self._api.get_doorbell_detail( + self._access_token, doorbell.device_id) + + self._doorbell_detail_by_id = detail_by_id + + def get_lock_status(self, lock_id): + """Return lock status.""" + self._update_locks() + return self._lock_status_by_id.get(lock_id) + + def get_lock_detail(self, lock_id): + """Return lock detail.""" + self._update_locks() + return self._lock_detail_by_id.get(lock_id) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_locks(self): + status_by_id = {} + detail_by_id = {} + + for lock in self._locks: + status_by_id[lock.device_id] = self._api.get_lock_status( + self._access_token, lock.device_id) + detail_by_id[lock.device_id] = self._api.get_lock_detail( + self._access_token, lock.device_id) + + self._lock_status_by_id = status_by_id + self._lock_detail_by_id = detail_by_id + + def lock(self, device_id): + """Lock the device.""" + return self._api.lock(self._access_token, device_id) + + def unlock(self, device_id): + """Unlock the device.""" + return self._api.unlock(self._access_token, device_id) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py new file mode 100644 index 0000000000000..0f7295a41e093 --- /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 0000000000000..122c303218822 --- /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 58c86ff0c6d32..2f510fd33d6db 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -6,15 +6,14 @@ """ import asyncio from functools import partial +import importlib import logging -import os import voluptuous as vol from homeassistant.setup import async_prepare_setup_platform from homeassistant.core import CoreState from homeassistant.loader import bind_hass -from homeassistant import config as conf_util from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, SERVICE_RELOAD, EVENT_HOMEASSISTANT_START, CONF_ID) @@ -24,7 +23,6 @@ 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 @@ -60,12 +58,14 @@ 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( @@ -73,7 +73,7 @@ def _platform_validator(config): [ vol.All( vol.Schema({ - vol.Required(CONF_PLATFORM): cv.platform_validator(DOMAIN) + vol.Required(CONF_PLATFORM): str }, extra=vol.ALLOW_EXTRA), _platform_validator ), @@ -166,11 +166,6 @@ def async_setup(hass, config): yield from _async_process_config(hass, config, component) - descriptions = yield from hass.async_add_job( - conf_util.load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) - @asyncio.coroutine def trigger_service_handler(service_call): """Handle automation triggers.""" @@ -216,20 +211,20 @@ def reload_service_handler(service_call): hass.services.async_register( DOMAIN, SERVICE_TRIGGER, trigger_service_handler, - descriptions.get(SERVICE_TRIGGER), schema=TRIGGER_SERVICE_SCHEMA) + schema=TRIGGER_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_RELOAD, reload_service_handler, - descriptions.get(SERVICE_RELOAD), schema=RELOAD_SERVICE_SCHEMA) + schema=RELOAD_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TOGGLE, toggle_service_handler, - descriptions.get(SERVICE_TOGGLE), schema=SERVICE_SCHEMA) + schema=SERVICE_SCHEMA) for service in (SERVICE_TURN_ON, SERVICE_TURN_OFF): hass.services.async_register( DOMAIN, service, turn_onoff_service_handler, - descriptions.get(service), schema=SERVICE_SCHEMA) + schema=SERVICE_SCHEMA) return True @@ -345,10 +340,9 @@ def async_trigger(self, variables, skip_condition=False): yield from self.async_update_ha_state() @asyncio.coroutine - def async_remove(self): - """Remove automation from HASS.""" + def async_will_remove_from_hass(self): + """Remove listeners when removing automation from HASS.""" yield from self.async_turn_off() - yield from super().async_remove() @asyncio.coroutine def async_enable(self): diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 32d2d245bef24..7c035d7d1a5f7 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -29,18 +29,27 @@ def async_trigger(hass, config, action): """Listen for events based on configuration.""" event_type = config.get(CONF_EVENT_TYPE) - event_data = config.get(CONF_EVENT_DATA) + event_data_schema = vol.Schema( + config.get(CONF_EVENT_DATA), + extra=vol.ALLOW_EXTRA) if config.get(CONF_EVENT_DATA) else None @callback def handle_event(event): """Listen for events and calls the action when data matches.""" - if not event_data or all(val == event.data.get(key) for key, val - in event_data.items()): - hass.async_run_job(action, { - 'trigger': { - 'platform': 'event', - 'event': event, - }, - }) + if event_data_schema: + # Check that the event data matches the configured + # schema if one was provided + try: + event_data_schema(event.data) + except vol.Invalid: + # If event data doesn't match requested schema, skip event + return + + hass.async_run_job(action, { + 'trigger': { + 'platform': 'event', + 'event': event, + }, + }) return hass.bus.async_listen(event_type, handle_event) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 51b2ea89f0f1c..b59271f25e56c 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -37,14 +37,15 @@ def async_trigger(hass, config, action): above = config.get(CONF_ABOVE) time_delta = config.get(CONF_FOR) value_template = config.get(CONF_VALUE_TEMPLATE) - async_remove_track_same = None + unsub_track_same = {} + entities_triggered = set() if value_template is not None: value_template.hass = hass @callback def check_numeric_state(entity, from_s, to_s): - """Return True if they should trigger.""" + """Return True if criteria are now met.""" if to_s is None: return False @@ -56,51 +57,39 @@ def check_numeric_state(entity, from_s, to_s): 'above': above, } } - - # If new one doesn't match, nothing to do - if not condition.async_numeric_state( - hass, to_s, below, above, value_template, variables): - return False - - return True + return condition.async_numeric_state( + hass, to_s, below, above, value_template, variables) @callback def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" - nonlocal async_remove_track_same - - if not check_numeric_state(entity, from_s, to_s): - return - - variables = { - 'trigger': { - 'platform': 'numeric_state', - 'entity_id': entity, - 'below': below, - 'above': above, - 'from_state': from_s, - 'to_state': to_s, - } - } - - # Only match if old didn't exist or existed but didn't match - # Written as: skip if old one did exist and matched - if from_s is not None and condition.async_numeric_state( - hass, from_s, below, above, value_template, variables): - return - @callback def call_action(): """Call action with right context.""" - hass.async_run_job(action, variables) - - if not time_delta: - call_action() - return - - async_remove_track_same = async_track_same_state( - hass, True, time_delta, call_action, entity_ids=entity_id, - async_check_func=check_numeric_state) + hass.async_run_job(action, { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': entity, + 'below': below, + 'above': above, + 'from_state': from_s, + 'to_state': to_s, + } + }) + + matching = check_numeric_state(entity, from_s, to_s) + + if not matching: + entities_triggered.discard(entity) + elif entity not in entities_triggered: + entities_triggered.add(entity) + + if time_delta: + unsub_track_same[entity] = async_track_same_state( + hass, time_delta, call_action, entity_ids=entity_id, + async_check_same_func=check_numeric_state) + else: + call_action() unsub = async_track_state_change( hass, entity_id, state_automation_listener) @@ -109,7 +98,8 @@ def call_action(): def async_remove(): """Remove state listeners async.""" unsub() - if async_remove_track_same: - async_remove_track_same() # pylint: disable=not-callable + for async_remove in unsub_track_same.values(): + async_remove() + unsub_track_same.clear() return async_remove diff --git a/homeassistant/components/automation/services.yaml b/homeassistant/components/automation/services.yaml index ee22b671eca0e..90f660367069c 100644 --- a/homeassistant/components/automation/services.yaml +++ b/homeassistant/components/automation/services.yaml @@ -1,6 +1,7 @@ +# Describes the format for available automation services + turn_on: description: Enable an automation. - fields: entity_id: description: Name of the automation to turn on. @@ -8,7 +9,6 @@ turn_on: turn_off: description: Disable an automation. - fields: entity_id: description: Name of the automation to turn off. @@ -16,7 +16,6 @@ turn_off: toggle: description: Toggle an automation. - fields: entity_id: description: Name of the automation to toggle on/off. @@ -24,7 +23,6 @@ toggle: trigger: description: Trigger the action of an automation. - fields: entity_id: description: Name of the automation to trigger. diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index e7a01cb711582..9243f960850ee 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -35,13 +35,11 @@ def async_trigger(hass, config, action): to_state = config.get(CONF_TO, MATCH_ALL) time_delta = config.get(CONF_FOR) match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL) - async_remove_track_same = None + unsub_track_same = {} @callback def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" - nonlocal async_remove_track_same - @callback def call_action(): """Call action with right context.""" @@ -57,15 +55,17 @@ def call_action(): # Ignore changes to state attributes if from/to is in use if (not match_all and from_s is not None and to_s is not None and - from_s.last_changed == to_s.last_changed): + from_s.state == to_s.state): return if not time_delta: call_action() return - async_remove_track_same = async_track_same_state( - hass, to_s.state, time_delta, call_action, entity_ids=entity_id) + unsub_track_same[entity] = async_track_same_state( + hass, time_delta, call_action, + lambda _, _2, to_state: to_state.state == to_s.state, + entity_ids=entity_id) unsub = async_track_state_change( hass, entity_id, state_automation_listener, from_state, to_state) @@ -74,7 +74,8 @@ def call_action(): def async_remove(): """Remove state listeners async.""" unsub() - if async_remove_track_same: - async_remove_track_same() # pylint: disable=not-callable + for async_remove in unsub_track_same.values(): + async_remove() + unsub_track_same.clear() return async_remove diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index eaf859376582c..fab7d98ed9839 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -4,26 +4,22 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/axis/ """ - -import json import logging -import os import voluptuous as vol -from homeassistant.config import load_yaml_config_file -from homeassistant.const import (ATTR_LOCATION, ATTR_TRIPPED, - CONF_HOST, CONF_INCLUDE, CONF_NAME, - CONF_PASSWORD, CONF_TRIGGER_TIME, - CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) from homeassistant.components.discovery import SERVICE_AXIS +from homeassistant.const import ( + ATTR_LOCATION, ATTR_TRIPPED, CONF_EVENT, CONF_HOST, CONF_INCLUDE, + CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_TRIGGER_TIME, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity +from homeassistant.util.json import load_json, save_json - -REQUIREMENTS = ['axis==8'] +REQUIREMENTS = ['axis==14'] _LOGGER = logging.getLogger(__name__) @@ -51,6 +47,7 @@ vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_TRIGGER_TIME, default=0): cv.positive_int, + vol.Optional(CONF_PORT, default=80): cv.positive_int, vol.Optional(ATTR_LOCATION, default=''): cv.string, }) @@ -76,36 +73,40 @@ }) -def request_configuration(hass, name, host, serialnumber): +def request_configuration(hass, config, name, host, serialnumber): """Request configuration steps from the user.""" configurator = hass.components.configurator def configuration_callback(callback_data): - """Called when config is submitted.""" + """Call when configuration is submitted.""" if CONF_INCLUDE not in callback_data: - configurator.notify_errors(request_id, - "Functionality mandatory.") + configurator.notify_errors( + request_id, "Functionality mandatory.") return False + callback_data[CONF_INCLUDE] = callback_data[CONF_INCLUDE].split() callback_data[CONF_HOST] = host + if CONF_NAME not in callback_data: callback_data[CONF_NAME] = name + try: - config = DEVICE_SCHEMA(callback_data) + device_config = DEVICE_SCHEMA(callback_data) except vol.Invalid: - configurator.notify_errors(request_id, - "Bad input, please check spelling.") + configurator.notify_errors( + request_id, "Bad input, please check spelling.") return False - if setup_device(hass, config): - config_file = _read_config(hass) - config_file[serialnumber] = dict(config) - del config_file[serialnumber]['hass'] - _write_config(hass, config_file) + if setup_device(hass, config, device_config): + del device_config['events'] + del device_config['signal'] + config_file = load_json(hass.config.path(CONFIG_FILE)) + config_file[serialnumber] = dict(device_config) + save_json(hass.config.path(CONFIG_FILE), config_file) configurator.request_done(request_id) else: - configurator.notify_errors(request_id, - "Failed to register, please try again.") + configurator.notify_errors( + request_id, "Failed to register, please try again.") return False title = '{} ({})'.format(name, host) @@ -132,6 +133,9 @@ def configuration_callback(callback_data): {'id': ATTR_LOCATION, 'name': "Physical location of device (optional)", 'type': 'text'}, + {'id': CONF_PORT, + 'name': "HTTP port (default=80)", + 'type': 'number'}, {'id': CONF_TRIGGER_TIME, 'name': "Sensor update interval (optional)", 'type': 'number'}, @@ -139,157 +143,131 @@ def configuration_callback(callback_data): ) -def setup(hass, base_config): - """Common setup for Axis devices.""" +def setup(hass, config): + """Set up for Axis devices.""" def _shutdown(call): # pylint: disable=unused-argument - """Stop the metadatastream on shutdown.""" + """Stop the event stream on shutdown.""" for serialnumber, device in AXIS_DEVICES.items(): - _LOGGER.info("Stopping metadatastream for %s.", serialnumber) - device.stop_metadatastream() + _LOGGER.info("Stopping event stream for %s.", serialnumber) + device.stop() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) def axis_device_discovered(service, discovery_info): - """Called when axis devices has been found.""" + """Call when axis devices has been found.""" host = discovery_info[CONF_HOST] name = discovery_info['hostname'] serialnumber = discovery_info['properties']['macaddress'] if serialnumber not in AXIS_DEVICES: - config_file = _read_config(hass) + config_file = load_json(hass.config.path(CONFIG_FILE)) if serialnumber in config_file: - # Device config saved to file + # Device config previously saved to file try: - config = DEVICE_SCHEMA(config_file[serialnumber]) - config[CONF_HOST] = host + device_config = DEVICE_SCHEMA(config_file[serialnumber]) + device_config[CONF_HOST] = host except vol.Invalid as err: _LOGGER.error("Bad data from %s. %s", CONFIG_FILE, err) return False - if not setup_device(hass, config): - _LOGGER.error("Couldn\'t set up %s", config[CONF_NAME]) + if not setup_device(hass, config, device_config): + _LOGGER.error( + "Couldn't set up %s", device_config[CONF_NAME]) else: # New device, create configuration request for UI - request_configuration(hass, name, host, serialnumber) + request_configuration(hass, config, name, host, serialnumber) else: # Device already registered, but on a different IP device = AXIS_DEVICES[serialnumber] - device.url = host - async_dispatcher_send(hass, - DOMAIN + '_' + device.name + '_new_ip', - host) + device.config.host = host + dispatcher_send(hass, DOMAIN + '_' + device.name + '_new_ip', host) # Register discovery service discovery.listen(hass, SERVICE_AXIS, axis_device_discovered) - if DOMAIN in base_config: - for device in base_config[DOMAIN]: - config = base_config[DOMAIN][device] - if CONF_NAME not in config: - config[CONF_NAME] = device - if not setup_device(hass, config): - _LOGGER.error("Couldn\'t set up %s", config[CONF_NAME]) - - # Services to communicate with device. - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) + if DOMAIN in config: + for device in config[DOMAIN]: + device_config = config[DOMAIN][device] + if CONF_NAME not in device_config: + device_config[CONF_NAME] = device + if not setup_device(hass, config, device_config): + _LOGGER.error("Couldn't set up %s", device_config[CONF_NAME]) def vapix_service(call): """Service to send a message.""" for _, device in AXIS_DEVICES.items(): if device.name == call.data[CONF_NAME]: - response = device.do_request(call.data[SERVICE_CGI], - call.data[SERVICE_ACTION], - call.data[SERVICE_PARAM]) - hass.bus.async_fire(SERVICE_VAPIX_CALL_RESPONSE, response) + response = device.vapix.do_request( + call.data[SERVICE_CGI], + call.data[SERVICE_ACTION], + call.data[SERVICE_PARAM]) + hass.bus.fire(SERVICE_VAPIX_CALL_RESPONSE, response) return True - _LOGGER.info("Couldn\'t find device %s", call.data[CONF_NAME]) + _LOGGER.info("Couldn't find device %s", call.data[CONF_NAME]) return False # Register service with Home Assistant. - hass.services.register(DOMAIN, - SERVICE_VAPIX_CALL, - vapix_service, - descriptions[DOMAIN][SERVICE_VAPIX_CALL], - schema=SERVICE_SCHEMA) - + hass.services.register( + DOMAIN, SERVICE_VAPIX_CALL, vapix_service, schema=SERVICE_SCHEMA) return True -def setup_device(hass, config): - """Set up device.""" +def setup_device(hass, config, device_config): + """Set up an Axis device.""" from axis import AxisDevice - config['hass'] = hass - device = AxisDevice(config) # Initialize device - enable_metadatastream = False + def signal_callback(action, event): + """Call to configure events when initialized on event stream.""" + if action == 'add': + event_config = { + CONF_EVENT: event, + CONF_NAME: device_config[CONF_NAME], + ATTR_LOCATION: device_config[ATTR_LOCATION], + CONF_TRIGGER_TIME: device_config[CONF_TRIGGER_TIME] + } + component = event.event_platform + discovery.load_platform( + hass, component, DOMAIN, event_config, config) + + event_types = list(filter(lambda x: x in device_config[CONF_INCLUDE], + EVENT_TYPES)) + device_config['events'] = event_types + device_config['signal'] = signal_callback + device = AxisDevice(hass.loop, **device_config) + device.name = device_config[CONF_NAME] if device.serial_number is None: # If there is no serial number a connection could not be made - _LOGGER.error("Couldn\'t connect to %s", config[CONF_HOST]) + _LOGGER.error("Couldn't connect to %s", device_config[CONF_HOST]) return False - for component in config[CONF_INCLUDE]: - if component in EVENT_TYPES: - # Sensors are created by device calling event_initialized - # when receiving initialize messages on metadatastream - device.add_event_topic(convert(component, 'type', 'subscribe')) - if not enable_metadatastream: - enable_metadatastream = True - else: - discovery.load_platform(hass, component, DOMAIN, config) - - if enable_metadatastream: - device.initialize_new_event = event_initialized - if not device.initiate_metadatastream(): - hass.components.persistent_notification.create( - 'Dependency missing for sensors, ' - 'please check documentation', - title=DOMAIN, - notification_id='axis_notification') + for component in device_config[CONF_INCLUDE]: + if component == 'camera': + camera_config = { + CONF_NAME: device_config[CONF_NAME], + CONF_HOST: device_config[CONF_HOST], + CONF_PORT: device_config[CONF_PORT], + CONF_USERNAME: device_config[CONF_USERNAME], + CONF_PASSWORD: device_config[CONF_PASSWORD] + } + discovery.load_platform( + hass, component, DOMAIN, camera_config, config) AXIS_DEVICES[device.serial_number] = device - + if event_types: + hass.add_job(device.start) return True -def _read_config(hass): - """Read Axis config.""" - path = hass.config.path(CONFIG_FILE) - - if not os.path.isfile(path): - return {} - - with open(path) as f_handle: - # Guard against empty file - return json.loads(f_handle.read() or '{}') - - -def _write_config(hass, config): - """Write Axis config.""" - data = json.dumps(config) - with open(hass.config.path(CONFIG_FILE), 'w', encoding='utf-8') as outfile: - outfile.write(data) - - -def event_initialized(event): - """Register event initialized on metadatastream here.""" - hass = event.device_config('hass') - discovery.load_platform(hass, - convert(event.topic, 'topic', 'platform'), - DOMAIN, {'axis_event': event}) - - class AxisDeviceEvent(Entity): """Representation of a Axis device event.""" - def __init__(self, axis_event): + def __init__(self, event_config): """Initialize the event.""" - self.axis_event = axis_event - self._event_class = convert(self.axis_event.topic, 'topic', 'class') - self._name = '{}_{}_{}'.format(self.axis_event.device_name, - convert(self.axis_event.topic, - 'topic', 'type'), - self.axis_event.id) + self.axis_event = event_config[CONF_EVENT] + self._name = '{}_{}_{}'.format( + event_config[CONF_NAME], self.axis_event.event_type, + self.axis_event.id) + self.location = event_config[ATTR_LOCATION] self.axis_event.callback = self._update_callback def _update_callback(self): @@ -305,11 +283,11 @@ def name(self): @property def device_class(self): """Return the class of the event.""" - return self._event_class + return self.axis_event.event_class @property def should_poll(self): - """No polling needed.""" + """Return the polling state. No polling needed.""" return False @property @@ -320,52 +298,6 @@ def device_state_attributes(self): tripped = self.axis_event.is_tripped attr[ATTR_TRIPPED] = 'True' if tripped else 'False' - location = self.axis_event.device_config(ATTR_LOCATION) - if location: - attr[ATTR_LOCATION] = location + attr[ATTR_LOCATION] = self.location return attr - - -def convert(item, from_key, to_key): - """Translate between Axis and HASS syntax.""" - for entry in REMAP: - if entry[from_key] == item: - return entry[to_key] - - -REMAP = [{'type': 'motion', - 'class': 'motion', - 'topic': 'tns1:VideoAnalytics/tnsaxis:MotionDetection', - 'subscribe': 'onvif:VideoAnalytics/axis:MotionDetection', - 'platform': 'binary_sensor'}, - {'type': 'vmd3', - 'class': 'motion', - 'topic': 'tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1', - 'subscribe': 'onvif:RuleEngine/axis:VMD3/vmd3_video_1', - 'platform': 'binary_sensor'}, - {'type': 'pir', - 'class': 'motion', - 'topic': 'tns1:Device/tnsaxis:Sensor/PIR', - 'subscribe': 'onvif:Device/axis:Sensor/axis:PIR', - 'platform': 'binary_sensor'}, - {'type': 'sound', - 'class': 'sound', - 'topic': 'tns1:AudioSource/tnsaxis:TriggerLevel', - 'subscribe': 'onvif:AudioSource/axis:TriggerLevel', - 'platform': 'binary_sensor'}, - {'type': 'daynight', - 'class': 'light', - 'topic': 'tns1:VideoSource/tnsaxis:DayNightVision', - 'subscribe': 'onvif:VideoSource/axis:DayNightVision', - 'platform': 'binary_sensor'}, - {'type': 'tampering', - 'class': 'safety', - 'topic': 'tns1:VideoSource/tnsaxis:Tampering', - 'subscribe': 'onvif:VideoSource/axis:Tampering', - 'platform': 'binary_sensor'}, - {'type': 'input', - 'class': 'input', - 'topic': 'tns1:Device/tnsaxis:IO/Port', - 'subscribe': 'onvif:Device/axis:IO/Port', - 'platform': 'binary_sensor'}, ] diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 4ba29e9b2ba96..d72211d5ad1e1 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -4,7 +4,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/binary_sensor/ """ -import asyncio + from datetime import timedelta import logging @@ -20,36 +20,53 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' DEVICE_CLASSES = [ - 'cold', # On means cold (or too cold) - 'connectivity', # On means connection present, Off = no connection - 'gas', # CO, CO2, etc. - 'heat', # On means hot (or too hot) - 'light', # Lightness threshold - 'moisture', # Specifically a wetness sensor - 'motion', # Motion sensor - 'moving', # On means moving, Off means stopped - 'occupancy', # On means occupied, Off means not occupied - 'opening', # Door, window, etc. - 'power', # Power, over-current, etc - 'safety', # Generic on=unsafe, off=safe - 'smoke', # Smoke detector - 'sound', # On means sound detected, Off means no sound + 'battery', # On means low, Off means normal + 'cold', # On means cold, Off means normal + 'connectivity', # On means connected, Off means disconnected + 'door', # On means open, Off means closed + 'garage_door', # On means open, Off means closed + 'gas', # On means gas detected, Off means no gas (clear) + 'heat', # On means hot, Off means normal + 'light', # On means light detected, Off means no light + 'lock', # On means open (unlocked), Off means closed (locked) + 'moisture', # On means wet, Off means dry + 'motion', # On means motion detected, Off means no motion (clear) + 'moving', # On means moving, Off means not moving (stopped) + 'occupancy', # On means occupied, Off means not occupied (clear) + 'opening', # On means open, Off means closed + 'plug', # On means plugged in, Off means unplugged + 'power', # On means power detected, Off means no power + 'presence', # On means home, Off means away + 'problem', # On means problem detected, Off means no problem (OK) + 'safety', # On means unsafe, Off means safe + 'smoke', # On means smoke detected, Off means no smoke (clear) + 'sound', # On means sound detected, Off means no sound (clear) 'vibration', # On means vibration detected, Off means no vibration + 'window', # On means open, Off means closed ] DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) -@asyncio.coroutine -def async_setup(hass, config): +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) - yield from component.async_setup(config) + 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/abode.py b/homeassistant/components/binary_sensor/abode.py index d3b0d662a9466..8ad401589584e 100644 --- a/homeassistant/components/binary_sensor/abode.py +++ b/homeassistant/components/binary_sensor/abode.py @@ -6,7 +6,8 @@ """ import logging -from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.abode import (AbodeDevice, AbodeAutomation, + DOMAIN as ABODE_DOMAIN) from homeassistant.components.binary_sensor import BinarySensorDevice @@ -17,39 +18,38 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - abode = hass.data[DATA_ABODE] + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE - device_types = map_abode_device_class().keys() + data = hass.data[ABODE_DOMAIN] - sensors = [] - for sensor in abode.get_devices(type_filter=device_types): - sensors.append(AbodeBinarySensor(abode, sensor)) + device_types = [CONST.TYPE_CONNECTIVITY, CONST.TYPE_MOISTURE, + CONST.TYPE_MOTION, CONST.TYPE_OCCUPANCY, + CONST.TYPE_OPENING] - add_devices(sensors) + devices = [] + for device in data.abode.get_devices(generic_type=device_types): + if data.is_excluded(device): + continue + devices.append(AbodeBinarySensor(data, device)) -def map_abode_device_class(): - """Map Abode device types to Home Assistant binary sensor class.""" - import abodepy.helpers.constants as CONST + for automation in data.abode.get_automations( + generic_type=CONST.TYPE_QUICK_ACTION): + if data.is_automation_excluded(automation): + continue + + devices.append(AbodeQuickActionBinarySensor( + data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)) - return { - CONST.DEVICE_GLASS_BREAK: 'connectivity', - CONST.DEVICE_KEYPAD: 'connectivity', - CONST.DEVICE_DOOR_CONTACT: 'opening', - CONST.DEVICE_STATUS_DISPLAY: 'connectivity', - CONST.DEVICE_MOTION_CAMERA: 'connectivity', - CONST.DEVICE_WATER_SENSOR: 'moisture' - } + data.devices.extend(devices) + + add_devices(devices) class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): """A binary sensor implementation for Abode device.""" - def __init__(self, controller, device): - """Initialize a sensor for Abode device.""" - AbodeDevice.__init__(self, controller, device) - self._device_class = map_abode_device_class().get(self._device.type) - @property def is_on(self): """Return True if the binary sensor is on.""" @@ -58,4 +58,17 @@ def is_on(self): @property def device_class(self): """Return the class of the binary sensor.""" - return self._device_class + return self._device.generic_type + + +class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice): + """A binary sensor implementation for Abode quick action automations.""" + + def trigger(self): + """Trigger a quick automation.""" + self._automation.trigger() + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._automation.is_active diff --git a/homeassistant/components/binary_sensor/ads.py b/homeassistant/components/binary_sensor/ads.py new file mode 100644 index 0000000000000..b7f0ebcc9d3da --- /dev/null +++ b/homeassistant/components/binary_sensor/ads.py @@ -0,0 +1,84 @@ +""" +Support for ADS binary sensors. + +For more details about this platform, please refer to the documentation. +https://home-assistant.io/components/binary_sensor.ads/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components.ads import CONF_ADS_VAR, DATA_ADS +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'ADS binary sensor' +DEPENDENCIES = ['ads'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADS_VAR): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Binary Sensor platform for ADS.""" + ads_hub = hass.data.get(DATA_ADS) + + ads_var = config.get(CONF_ADS_VAR) + name = config.get(CONF_NAME) + device_class = config.get(CONF_DEVICE_CLASS) + + ads_sensor = AdsBinarySensor(ads_hub, name, ads_var, device_class) + add_devices([ads_sensor]) + + +class AdsBinarySensor(BinarySensorDevice): + """Representation of ADS binary sensors.""" + + def __init__(self, ads_hub, name, ads_var, device_class): + """Initialize ADS binary sensor.""" + self._name = name + self._state = False + self._device_class = device_class or 'moving' + self._ads_hub = ads_hub + self.ads_var = ads_var + + @asyncio.coroutine + def async_added_to_hass(self): + """Register device notification.""" + def update(name, value): + """Handle device notifications.""" + _LOGGER.debug('Variable %s changed its value to %d', name, value) + self._state = value + self.schedule_update_ha_state() + + self.hass.async_add_job( + self._ads_hub.add_device_notification, + self.ads_var, self._ads_hub.PLCTYPE_BOOL, update) + + @property + def name(self): + """Return the default name of the binary sensor.""" + return self._name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def is_on(self): + """Return if the binary sensor is on.""" + return self._state + + @property + def should_poll(self): + """Return False because entity pushes its state to HA.""" + return False diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index 495feaf64ab86..f0c8ec2d97ce4 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -7,39 +7,41 @@ import asyncio import logging -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.binary_sensor import BinarySensorDevice - -from homeassistant.components.alarmdecoder import (ZONE_SCHEMA, - CONF_ZONES, - CONF_ZONE_NAME, - CONF_ZONE_TYPE, - SIGNAL_ZONE_FAULT, - SIGNAL_ZONE_RESTORE) - +from homeassistant.components.alarmdecoder import ( + ZONE_SCHEMA, CONF_ZONES, CONF_ZONE_NAME, CONF_ZONE_TYPE, + CONF_ZONE_RFID, SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE, + SIGNAL_RFX_MESSAGE) DEPENDENCIES = ['alarmdecoder'] _LOGGER = logging.getLogger(__name__) +ATTR_RF_BIT0 = 'rf_bit0' +ATTR_RF_LOW_BAT = 'rf_low_battery' +ATTR_RF_SUPERVISED = 'rf_supervised' +ATTR_RF_BIT3 = 'rf_bit3' +ATTR_RF_LOOP3 = 'rf_loop3' +ATTR_RF_LOOP2 = 'rf_loop2' +ATTR_RF_LOOP4 = 'rf_loop4' +ATTR_RF_LOOP1 = 'rf_loop1' -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the AlarmDecoder binary sensor devices.""" configured_zones = discovery_info[CONF_ZONES] devices = [] - for zone_num in configured_zones: device_config_data = ZONE_SCHEMA(configured_zones[zone_num]) zone_type = device_config_data[CONF_ZONE_TYPE] zone_name = device_config_data[CONF_ZONE_NAME] + zone_rfid = device_config_data.get(CONF_ZONE_RFID) device = AlarmDecoderBinarySensor( - hass, zone_num, zone_name, zone_type) + zone_num, zone_name, zone_type, zone_rfid) devices.append(device) - async_add_devices(devices) + add_devices(devices) return True @@ -47,46 +49,52 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class AlarmDecoderBinarySensor(BinarySensorDevice): """Representation of an AlarmDecoder binary sensor.""" - def __init__(self, hass, zone_number, zone_name, zone_type): + def __init__(self, zone_number, zone_name, zone_type, zone_rfid): """Initialize the binary_sensor.""" self._zone_number = zone_number self._zone_type = zone_type - self._state = 0 + self._state = None self._name = zone_name - self._type = zone_type - - _LOGGER.debug("Setup up zone: %s", self._name) + self._rfid = zone_rfid + self._rfstate = None @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_ZONE_FAULT, self._fault_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_FAULT, self._fault_callback) + + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_RESTORE, self._restore_callback) - async_dispatcher_connect( - self.hass, SIGNAL_ZONE_RESTORE, self._restore_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_RFX_MESSAGE, self._rfx_message_callback) @property def name(self): """Return the name of the entity.""" return self._name - @property - def icon(self): - """Icon for device by its type.""" - if "window" in self._name.lower(): - return "mdi:window-open" if self.is_on else "mdi:window-closed" - - if self._type == 'smoke': - return "mdi:fire" - - return None - @property def should_poll(self): """No polling needed.""" return False + @property + def device_state_attributes(self): + """Return the state attributes.""" + attr = {} + if self._rfid and self._rfstate is not None: + attr[ATTR_RF_BIT0] = True if self._rfstate & 0x01 else False + attr[ATTR_RF_LOW_BAT] = True if self._rfstate & 0x02 else False + attr[ATTR_RF_SUPERVISED] = True if self._rfstate & 0x04 else False + attr[ATTR_RF_BIT3] = True if self._rfstate & 0x08 else False + attr[ATTR_RF_LOOP3] = True if self._rfstate & 0x10 else False + attr[ATTR_RF_LOOP2] = True if self._rfstate & 0x20 else False + attr[ATTR_RF_LOOP4] = True if self._rfstate & 0x40 else False + attr[ATTR_RF_LOOP1] = True if self._rfstate & 0x80 else False + return attr + @property def is_on(self): """Return true if sensor is on.""" @@ -97,16 +105,20 @@ def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" return self._zone_type - @callback def _fault_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 1 - self.hass.async_add_job(self.async_update_ha_state()) + self.schedule_update_ha_state() - @callback def _restore_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 0 - self.hass.async_add_job(self.async_update_ha_state()) + self.schedule_update_ha_state() + + def _rfx_message_callback(self, message): + """Update RF state.""" + if self._rfid and message and message.serial_number == self._rfid: + self._rfstate = message.value + self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/august.py b/homeassistant/components/binary_sensor/august.py new file mode 100644 index 0000000000000..8df50a1bfb6bc --- /dev/null +++ b/homeassistant/components/binary_sensor/august.py @@ -0,0 +1,97 @@ +""" +Support for August binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.august/ +""" +from datetime import timedelta, datetime + +from homeassistant.components.august import DATA_AUGUST +from homeassistant.components.binary_sensor import (BinarySensorDevice) + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def _retrieve_online_state(data, doorbell): + """Get the latest state of the sensor.""" + detail = data.get_doorbell_detail(doorbell.device_id) + return detail.is_online + + +def _retrieve_motion_state(data, doorbell): + from august.activity import ActivityType + return _activity_time_based_state(data, doorbell, + [ActivityType.DOORBELL_MOTION, + ActivityType.DOORBELL_DING]) + + +def _retrieve_ding_state(data, doorbell): + from august.activity import ActivityType + return _activity_time_based_state(data, doorbell, + [ActivityType.DOORBELL_DING]) + + +def _activity_time_based_state(data, doorbell, activity_types): + """Get the latest state of the sensor.""" + latest = data.get_latest_device_activity(doorbell.device_id, + *activity_types) + + if latest is not None: + start = latest.activity_start_time + end = latest.activity_end_time + timedelta(seconds=30) + return start <= datetime.now() <= end + return None + + +# Sensor types: Name, device_class, state_provider +SENSOR_TYPES = { + 'doorbell_ding': ['Ding', 'occupancy', _retrieve_ding_state], + 'doorbell_motion': ['Motion', 'motion', _retrieve_motion_state], + 'doorbell_online': ['Online', 'connectivity', _retrieve_online_state], +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the August binary sensors.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for doorbell in data.doorbells: + for sensor_type in SENSOR_TYPES: + devices.append(AugustBinarySensor(data, sensor_type, doorbell)) + + add_devices(devices, True) + + +class AugustBinarySensor(BinarySensorDevice): + """Representation of an August binary sensor.""" + + def __init__(self, data, sensor_type, doorbell): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._doorbell = doorbell + self._state = None + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SENSOR_TYPES[self._sensor_type][1] + + @property + def name(self): + """Return the name of the binary sensor.""" + return "{} {}".format(self._doorbell.device_name, + SENSOR_TYPES[self._sensor_type][0]) + + def update(self): + """Get the latest state of the sensor.""" + state_provider = SENSOR_TYPES[self._sensor_type][2] + self._state = state_provider(self._data, self._doorbell) diff --git a/homeassistant/components/binary_sensor/aurora.py b/homeassistant/components/binary_sensor/aurora.py index 2530fecb7c10f..772792f5785a1 100644 --- a/homeassistant/components/binary_sensor/aurora.py +++ b/homeassistant/components/binary_sensor/aurora.py @@ -7,25 +7,32 @@ from datetime import timedelta import logging +from aiohttp.hdrs import USER_AGENT import requests import voluptuous as vol -from homeassistant.components.binary_sensor \ - import (BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_NAME) +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -CONF_THRESHOLD = "forecast_threshold" - _LOGGER = logging.getLogger(__name__) +CONF_ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric" \ + "Administration" +CONF_THRESHOLD = 'forecast_threshold' + +DEFAULT_DEVICE_CLASS = 'visible' DEFAULT_NAME = 'Aurora Visibility' -DEFAULT_DEVICE_CLASS = "visible" DEFAULT_THRESHOLD = 75 +HA_USER_AGENT = "Home Assistant Aurora Tracker v.0.1.0" + MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) +URL = "http://services.swpc.noaa.gov/text/aurora-nowcast-map.txt" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_THRESHOLD, default=DEFAULT_THRESHOLD): cv.positive_int, @@ -43,10 +50,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): try: aurora_data = AuroraData( - hass.config.latitude, - hass.config.longitude, - threshold - ) + hass.config.latitude, hass.config.longitude, threshold) aurora_data.update() except requests.exceptions.HTTPError as error: _LOGGER.error( @@ -85,9 +89,9 @@ def device_state_attributes(self): attrs = {} if self.aurora_data: - attrs["visibility_level"] = self.aurora_data.visibility_level - attrs["message"] = self.aurora_data.is_visible_text - + attrs['visibility_level'] = self.aurora_data.visibility_level + attrs['message'] = self.aurora_data.is_visible_text + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION return attrs def update(self): @@ -104,10 +108,7 @@ def __init__(self, latitude, longitude, threshold): self.longitude = longitude self.number_of_latitude_intervals = 513 self.number_of_longitude_intervals = 1024 - self.api_url = \ - "http://services.swpc.noaa.gov/text/aurora-nowcast-map.txt" - self.headers = {"User-Agent": "Home Assistant Aurora Tracker v.0.1.0"} - + self.headers = {USER_AGENT: HA_USER_AGENT} self.threshold = int(threshold) self.is_visible = None self.is_visible_text = None @@ -132,14 +133,14 @@ def update(self): def get_aurora_forecast(self): """Get forecast data and parse for given long/lat.""" - raw_data = requests.get(self.api_url, headers=self.headers).text + raw_data = requests.get(URL, headers=self.headers, timeout=5).text forecast_table = [ row.strip(" ").split(" ") for row in raw_data.split("\n") if not row.startswith("#") ] - # convert lat and long for data points in table + # Convert lat and long for data points in table converted_latitude = round((self.latitude / 180) * self.number_of_latitude_intervals) converted_longitude = round((self.longitude / 360) diff --git a/homeassistant/components/binary_sensor/axis.py b/homeassistant/components/binary_sensor/axis.py index 125e9b33bd79f..84137d95b06bf 100644 --- a/homeassistant/components/binary_sensor/axis.py +++ b/homeassistant/components/binary_sensor/axis.py @@ -4,13 +4,12 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.axis/ """ - -import logging from datetime import timedelta +import logging -from homeassistant.components.binary_sensor import (BinarySensorDevice) -from homeassistant.components.axis import (AxisDeviceEvent) -from homeassistant.const import (CONF_TRIGGER_TIME) +from homeassistant.components.axis import AxisDeviceEvent +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import CONF_TRIGGER_TIME from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.util.dt import utcnow @@ -20,20 +19,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup Axis device event.""" - add_devices([AxisBinarySensor(discovery_info['axis_event'], hass)], True) + """Set up the Axis binary devices.""" + add_devices([AxisBinarySensor(hass, discovery_info)], True) class AxisBinarySensor(AxisDeviceEvent, BinarySensorDevice): """Representation of a binary Axis event.""" - def __init__(self, axis_event, hass): - """Initialize the binary sensor.""" + def __init__(self, hass, event_config): + """Initialize the Axis binary sensor.""" self.hass = hass self._state = False - self._delay = axis_event.device_config(CONF_TRIGGER_TIME) + self._delay = event_config[CONF_TRIGGER_TIME] self._timer = None - AxisDeviceEvent.__init__(self, axis_event) + AxisDeviceEvent.__init__(self, event_config) @property def is_on(self): @@ -56,7 +55,7 @@ def _update_callback(self): # Set timer to wait until updating the state def _delay_update(now): """Timer callback for sensor update.""" - _LOGGER.debug("%s Called delayed (%s sec) update.", + _LOGGER.debug("%s called delayed (%s sec) update", self._name, self._delay) self.schedule_update_ha_state() self._timer = None diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py index 4c62735a6f9fb..f3dbc912ade12 100644 --- a/homeassistant/components/binary_sensor/bayesian.py +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -22,6 +22,10 @@ _LOGGER = logging.getLogger(__name__) +ATTR_OBSERVATIONS = 'observations' +ATTR_PROBABILITY = 'probability' +ATTR_PROBABILITY_THRESHOLD = 'probability_threshold' + CONF_OBSERVATIONS = 'observations' CONF_PRIOR = 'prior' CONF_PROBABILITY_THRESHOLD = 'probability_threshold' @@ -29,7 +33,8 @@ CONF_P_GIVEN_T = 'prob_given_true' CONF_TO_STATE = 'to_state' -DEFAULT_NAME = 'BayesianBinary' +DEFAULT_NAME = "Bayesian Binary Sensor" +DEFAULT_PROBABILITY_THRESHOLD = 0.5 NUMERIC_STATE_SCHEMA = vol.Schema({ CONF_PLATFORM: 'numeric_state', @@ -49,16 +54,14 @@ }, required=True) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): - cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_DEVICE_CLASS): cv.string, - vol.Required(CONF_OBSERVATIONS): vol.Schema( - vol.All(cv.ensure_list, [vol.Any(NUMERIC_STATE_SCHEMA, - STATE_SCHEMA)]) - ), + vol.Required(CONF_OBSERVATIONS): + vol.Schema(vol.All(cv.ensure_list, + [vol.Any(NUMERIC_STATE_SCHEMA, STATE_SCHEMA)])), vol.Required(CONF_PRIOR): vol.Coerce(float), - vol.Optional(CONF_PROBABILITY_THRESHOLD): - vol.Coerce(float), + vol.Optional(CONF_PROBABILITY_THRESHOLD, + default=DEFAULT_PROBABILITY_THRESHOLD): vol.Coerce(float), }) @@ -73,16 +76,16 @@ def update_probability(prior, prob_true, prob_false): @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the Threshold sensor.""" + """Set up the Bayesian Binary sensor.""" name = config.get(CONF_NAME) observations = config.get(CONF_OBSERVATIONS) prior = config.get(CONF_PRIOR) - probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD, 0.5) + probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD) device_class = config.get(CONF_DEVICE_CLASS) async_add_devices([ - BayesianBinarySensor(name, prior, observations, probability_threshold, - device_class) + BayesianBinarySensor( + name, prior, observations, probability_threshold, device_class) ], True) @@ -102,7 +105,13 @@ def __init__(self, name, prior, observations, probability_threshold, self.current_obs = OrderedDict({}) - self.entity_obs = {obs['entity_id']: obs for obs in self._observations} + to_observe = set(obs['entity_id'] for obs in self._observations) + + self.entity_obs = dict.fromkeys(to_observe, []) + + for ind, obs in enumerate(self._observations): + obs['id'] = ind + self.entity_obs[obs['entity_id']].append(obs) self.watchers = { 'numeric_state': self._process_numeric_state, @@ -111,7 +120,7 @@ def __init__(self, name, prior, observations, probability_threshold, @asyncio.coroutine def async_added_to_hass(self): - """Call when entity about to be added to hass.""" + """Call when entity about to be added.""" @callback # pylint: disable=invalid-name def async_threshold_sensor_state_listener(entity, old_state, @@ -120,17 +129,17 @@ def async_threshold_sensor_state_listener(entity, old_state, if new_state.state == STATE_UNKNOWN: return - entity_obs = self.entity_obs[entity] - platform = entity_obs['platform'] + entity_obs_list = self.entity_obs[entity] - self.watchers[platform](entity_obs) + for entity_obs in entity_obs_list: + platform = entity_obs['platform'] + + self.watchers[platform](entity_obs) prior = self.prior - print(self.current_obs.values()) for obs in self.current_obs.values(): - prior = update_probability(prior, obs['prob_true'], - obs['prob_false']) - + prior = update_probability( + prior, obs['prob_true'], obs['prob_false']) self.probability = prior self.hass.async_add_job(self.async_update_ha_state, True) @@ -141,20 +150,20 @@ def async_threshold_sensor_state_listener(entity, old_state, def _update_current_obs(self, entity_observation, should_trigger): """Update current observation.""" - entity = entity_observation['entity_id'] + obs_id = entity_observation['id'] if should_trigger: prob_true = entity_observation['prob_given_true'] prob_false = entity_observation.get( 'prob_given_false', 1 - prob_true) - self.current_obs[entity] = { + self.current_obs[obs_id] = { 'prob_true': prob_true, 'prob_false': prob_false } else: - self.current_obs.pop(entity, None) + self.current_obs.pop(obs_id, None) def _process_numeric_state(self, entity_observation): """Add entity to current_obs if numeric state conditions are met.""" @@ -200,9 +209,9 @@ def device_class(self): def device_state_attributes(self): """Return the state attributes of the sensor.""" return { - 'observations': [val for val in self.current_obs.values()], - 'probability': self.probability, - 'probability_threshold': self._probability_threshold + ATTR_OBSERVATIONS: [val for val in self.current_obs.values()], + ATTR_PROBABILITY: round(self.probability, 2), + ATTR_PROBABILITY_THRESHOLD: self._probability_threshold, } @asyncio.coroutine diff --git a/homeassistant/components/binary_sensor/bloomsky.py b/homeassistant/components/binary_sensor/bloomsky.py index 5e69dcc910909..3080cc6553251 100644 --- a/homeassistant/components/binary_sensor/bloomsky.py +++ b/homeassistant/components/binary_sensor/bloomsky.py @@ -11,7 +11,6 @@ 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__) @@ -24,14 +23,14 @@ } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up 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) @@ -50,7 +49,6 @@ def __init__(self, bs, device, sensor_name): self._device_id = device['DeviceID'] self._sensor_name = sensor_name self._name = '{} {}'.format(device['DeviceName'], sensor_name) - self._unique_id = 'bloomsky_binary_sensor {}'.format(self._name) self._state = None @property @@ -58,11 +56,6 @@ def name(self): """Return the name of the BloomSky device and this sensor.""" return self._name - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return self._unique_id - @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" 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 0000000000000..e214610f46dfe --- /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/concord232.py b/homeassistant/components/binary_sensor/concord232.py old mode 100755 new mode 100644 index 7ba88f766112d..f2acef47e820d --- a/homeassistant/components/binary_sensor/concord232.py +++ b/homeassistant/components/binary_sensor/concord232.py @@ -15,7 +15,7 @@ from homeassistant.const import (CONF_HOST, CONF_PORT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['concord232==0.14'] +REQUIREMENTS = ['concord232==0.15'] _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,7 @@ DEFAULT_PORT = '5007' DEFAULT_SSL = False -SCAN_INTERVAL = datetime.timedelta(seconds=1) +SCAN_INTERVAL = datetime.timedelta(seconds=10) ZONE_TYPES_SCHEMA = vol.Schema({ cv.positive_int: vol.In(DEVICE_CLASSES), @@ -53,7 +53,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensors = [] try: - _LOGGER.debug("Initializing Client") + _LOGGER.debug("Initializing client") client = concord232_client.Client('http://{}:{}'.format(host, port)) client.zones = client.list_zones() client.last_zone_update = datetime.datetime.now() @@ -62,6 +62,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) return False + # The order of zones returned by client.list_zones() can vary. + # When the zones are not named, this can result in the same entity + # name mapping to different sensors in an unpredictable way. Sort + # the zones by zone number to prevent this. + + client.zones.sort(key=lambda zone: zone['number']) + for zone in client.zones: _LOGGER.info("Loading Zone found: %s", zone['name']) if zone['number'] not in exclude: @@ -118,7 +125,7 @@ def name(self): def is_on(self): """Return true if the binary sensor is on.""" # True means "faulted" or "open" or "abnormal state" - return bool(self._zone['state'] == 'Normal') + return bool(self._zone['state'] != 'Normal') def update(self): """Get updated stats from API.""" diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py new file mode 100644 index 0000000000000..9faa703d13c00 --- /dev/null +++ b/homeassistant/components/binary_sensor/deconz.py @@ -0,0 +1,108 @@ +""" +Support for deCONZ binary sensor. + +For more details about this component, please refer to the documentation at +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, 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.""" + @callback + def async_add_sensor(sensors): + """Add binary sensor from deCONZ.""" + from pydeconz.sensor import DECONZ_BINARY_SENSOR + entities = [] + for sensor in sensors: + if sensor.type in DECONZ_BINARY_SENSOR: + entities.append(DeconzBinarySensor(sensor)) + async_add_devices(entities, True) + hass.data[DATA_DECONZ_UNSUB].append( + async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) + + async_add_sensor(hass.data[DATA_DECONZ].sensors.values()) + + +class DeconzBinarySensor(BinarySensorDevice): + """Representation of a binary sensor.""" + + def __init__(self, sensor): + """Set up sensor and add update callback to get data from websocket.""" + self._sensor = sensor + + async def async_added_to_hass(self): + """Subscribe sensors events.""" + self._sensor.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._sensor.deconz_id + + @callback + def async_update_callback(self, reason): + """Update the sensor's state. + + If reason is that state is updated, + or reachable has changed or battery has changed. + """ + if reason['state'] or \ + 'reachable' in reason['attr'] or \ + 'battery' in reason['attr']: + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._sensor.is_tripped + + @property + def name(self): + """Return the name of the sensor.""" + return self._sensor.name + + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + return self._sensor.uniqueid + + @property + def device_class(self): + """Return the class of the sensor.""" + return self._sensor.sensor_class + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._sensor.sensor_icon + + @property + def available(self): + """Return True if sensor is available.""" + return self._sensor.reachable + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + from pydeconz.sensor import PRESENCE + attr = {} + if self._sensor.battery: + attr[ATTR_BATTERY_LEVEL] = self._sensor.battery + if self._sensor.type in PRESENCE and self._sensor.dark: + attr['dark'] = self._sensor.dark + return attr diff --git a/homeassistant/components/binary_sensor/ecobee.py b/homeassistant/components/binary_sensor/ecobee.py index 214efb870b915..15efa21b226df 100644 --- a/homeassistant/components/binary_sensor/ecobee.py +++ b/homeassistant/components/binary_sensor/ecobee.py @@ -50,11 +50,6 @@ def is_on(self): """Return the status of the sensor.""" return self._state == 'true' - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return "binary_sensor_ecobee_{}_{}".format(self._name, self.index) - @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" diff --git a/homeassistant/components/binary_sensor/egardia.py b/homeassistant/components/binary_sensor/egardia.py new file mode 100644 index 0000000000000..76d90e78376be --- /dev/null +++ b/homeassistant/components/binary_sensor/egardia.py @@ -0,0 +1,78 @@ +""" +Interfaces with Egardia/Woonveilig alarm control panel. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.egardia/ +""" +import asyncio +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +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'} + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Initialize the platform.""" + if (discovery_info is None or + discovery_info[ATTR_DISCOVER_DEVICES] is None): + return + + disc_info = discovery_info[ATTR_DISCOVER_DEVICES] + # multiple devices here! + async_add_devices( + ( + EgardiaBinarySensor( + sensor_id=disc_info[sensor]['id'], + name=disc_info[sensor]['name'], + egardia_system=hass.data[EGARDIA_DEVICE], + device_class=EGARDIA_TYPE_TO_DEVICE_CLASS.get( + disc_info[sensor]['type'], None) + ) + for sensor in disc_info + ), True) + + +class EgardiaBinarySensor(BinarySensorDevice): + """Represents a sensor based on an Egardia sensor (IR, Door Contact).""" + + def __init__(self, sensor_id, name, egardia_system, device_class): + """Initialize the sensor device.""" + self._id = sensor_id + self._name = name + self._state = None + self._device_class = device_class + self._egardia_system = egardia_system + + def update(self): + """Update the status.""" + egardia_input = self._egardia_system.getsensorstate(self._id) + self._state = STATE_ON if egardia_input else STATE_OFF + + @property + def name(self): + """The name of the device.""" + return self._name + + @property + def is_on(self): + """Whether the device is switched on.""" + return self._state == STATE_ON + + @property + def hidden(self): + """Whether the device is hidden by default.""" + # these type of sensors are probably mainly used for automations + return True + + @property + def device_class(self): + """The device class.""" + return self._device_class diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index 5fbc1eb90a192..0aadcc247ea1e 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -50,7 +50,7 @@ def __init__(self, hass, zone_number, zone_name, zone_type, info, self._zone_type = zone_type self._zone_number = zone_number - _LOGGER.debug('Setting up zone: ' + zone_name) + _LOGGER.debug('Setting up zone: %s', zone_name) super().__init__(zone_name, info, controller) @asyncio.coroutine @@ -80,4 +80,4 @@ def device_class(self): def _update_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/ffmpeg_motion.py b/homeassistant/components/binary_sensor/ffmpeg_motion.py index 1bbf39dd6e063..75a9fa1d046c3 100644 --- a/homeassistant/components/binary_sensor/ffmpeg_motion.py +++ b/homeassistant/components/binary_sensor/ffmpeg_motion.py @@ -48,7 +48,7 @@ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the FFmpeg binary moition sensor.""" + """Set up the FFmpeg binary motion sensor.""" manager = hass.data[DATA_FFMPEG] if not manager.async_run_test(config.get(CONF_INPUT)): @@ -73,7 +73,7 @@ def __init__(self, config): def _async_callback(self, state): """HA-FFmpeg callback for noise detection.""" self._state = state - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def is_on(self): diff --git a/homeassistant/components/binary_sensor/flic.py b/homeassistant/components/binary_sensor/flic.py index 51fffae5cc027..170f1818a0eb4 100644 --- a/homeassistant/components/binary_sensor/flic.py +++ b/homeassistant/components/binary_sensor/flic.py @@ -238,6 +238,5 @@ def _connection_status_changed( import pyflic if connection_status == pyflic.ConnectionStatus.Disconnected: - _LOGGER.info("Button (%s) disconnected. Reason: %s", - self.address, disconnect_reason) - self.remove() + _LOGGER.warning("Button (%s) disconnected. Reason: %s", + self.address, disconnect_reason) diff --git a/homeassistant/components/binary_sensor/gc100.py b/homeassistant/components/binary_sensor/gc100.py new file mode 100644 index 0000000000000..c17e6b5091140 --- /dev/null +++ b/homeassistant/components/binary_sensor/gc100.py @@ -0,0 +1,69 @@ +""" +Support for binary sensor using GC100. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.gc100/ +""" +import voluptuous as vol + +from homeassistant.components.gc100 import DATA_GC100, CONF_PORTS +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import DEVICE_DEFAULT_NAME +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['gc100'] + +_SENSORS_SCHEMA = vol.Schema({ + cv.string: cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PORTS): vol.All(cv.ensure_list, [_SENSORS_SCHEMA]) +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the GC100 devices.""" + binary_sensors = [] + ports = config.get(CONF_PORTS) + for port in ports: + for port_addr, port_name in port.items(): + binary_sensors.append(GC100BinarySensor( + port_name, port_addr, hass.data[DATA_GC100])) + add_devices(binary_sensors, True) + + +class GC100BinarySensor(BinarySensorDevice): + """Representation of a binary sensor from GC100.""" + + def __init__(self, name, port_addr, gc100): + """Initialize the GC100 binary sensor.""" + # pylint: disable=no-member + self._name = name or DEVICE_DEFAULT_NAME + self._port_addr = port_addr + self._gc100 = gc100 + self._state = None + + # Subscribe to be notified about state changes (PUSH) + self._gc100.subscribe(self._port_addr, self.set_state) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the entity.""" + return self._state + + def update(self): + """Update the sensor state.""" + self._gc100.read_sensor(self._port_addr, self.set_state) + + def set_state(self, state): + """Set the current state.""" + self._state = state == 1 + self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index df488cc0ed600..f9ff4ac0a7a7c 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -18,7 +18,7 @@ CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) -REQUIREMENTS = ['pyhik==0.1.4'] +REQUIREMENTS = ['pyhik==0.1.8'] _LOGGER = logging.getLogger(__name__) CONF_IGNORED = 'ignored' @@ -48,6 +48,9 @@ 'Face Detection': 'motion', 'Scene Change Detection': 'motion', 'I/O': None, + 'Unattended Baggage': 'motion', + 'Attended Baggage': 'motion', + 'Recording Failure': None, } CUSTOMIZE_SCHEMA = vol.Schema({ @@ -56,7 +59,7 @@ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SSL, default=False): cv.boolean, @@ -118,7 +121,7 @@ class HikvisionData(object): """Hikvision device event stream object.""" def __init__(self, hass, url, port, name, username, password): - """Initialize the data oject.""" + """Initialize the data object.""" from pyhik.hikvision import HikCamera self._url = url self._port = port @@ -211,8 +214,8 @@ def name(self): @property def unique_id(self): - """Return an unique ID.""" - return '{}.{}'.format(self.__class__, self._id) + """Return a unique ID.""" + return self._id @property def is_on(self): diff --git a/homeassistant/components/binary_sensor/hive.py b/homeassistant/components/binary_sensor/hive.py new file mode 100644 index 0000000000000..46dd1b193e86f --- /dev/null +++ b/homeassistant/components/binary_sensor/hive.py @@ -0,0 +1,71 @@ +""" +Support for the Hive devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.hive/ +""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.hive import DATA_HIVE + +DEPENDENCIES = ['hive'] + +DEVICETYPE_DEVICE_CLASS = {'motionsensor': 'motion', + 'contactsensor': 'opening'} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Hive sensor devices.""" + if discovery_info is None: + return + session = hass.data.get(DATA_HIVE) + + add_devices([HiveBinarySensorEntity(session, discovery_info)]) + + +class HiveBinarySensorEntity(BinarySensorDevice): + """Representation of a Hive binary sensor.""" + + def __init__(self, hivesession, hivedevice): + """Initialize the hive sensor.""" + self.node_id = hivedevice["Hive_NodeID"] + self.node_name = hivedevice["Hive_NodeName"] + 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) + + self.session.entities.append(self) + + def handle_update(self, updatesource): + """Handle the new update request.""" + if '{}.{}'.format(self.device_type, self.node_id) not in updatesource: + self.schedule_update_ha_state() + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEVICETYPE_DEVICE_CLASS.get(self.node_device_type) + + @property + def name(self): + """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.""" + return self.session.sensor.get_state(self.node_id, + self.node_device_type) + + 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/homematic.py b/homeassistant/components/binary_sensor/homematic.py index 2f464bc73cc58..d85c10f9a34c8 100644 --- a/homeassistant/components/binary_sensor/homematic.py +++ b/homeassistant/components/binary_sensor/homematic.py @@ -25,6 +25,7 @@ 'RemoteMotion': None, 'WeatherSensor': None, 'TiltSensor': None, + 'PresenceIP': 'motion', } diff --git a/homeassistant/components/binary_sensor/homematicip_cloud.py b/homeassistant/components/binary_sensor/homematicip_cloud.py new file mode 100644 index 0000000000000..40ffe4984020c --- /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/ihc.py b/homeassistant/components/binary_sensor/ihc.py new file mode 100644 index 0000000000000..96efa6e6c1969 --- /dev/null +++ b/homeassistant/components/binary_sensor/ihc.py @@ -0,0 +1,96 @@ +"""IHC binary sensor platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.ihc/ +""" +from xml.etree.ElementTree import Element + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) +from homeassistant.components.ihc import ( + validate_name, IHC_DATA, IHC_CONTROLLER, IHC_INFO) +from homeassistant.components.ihc.const import CONF_INVERTING +from homeassistant.components.ihc.ihcdevice import IHCDevice +from homeassistant.const import ( + CONF_NAME, CONF_TYPE, CONF_ID, CONF_BINARY_SENSORS) +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['ihc'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_BINARY_SENSORS, default=[]): + vol.All(cv.ensure_list, [ + vol.All({ + vol.Required(CONF_ID): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_TYPE): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_INVERTING, default=False): cv.boolean, + }, validate_name) + ]) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the IHC binary sensor platform.""" + ihc_controller = hass.data[IHC_DATA][IHC_CONTROLLER] + info = hass.data[IHC_DATA][IHC_INFO] + devices = [] + if discovery_info: + for name, device in discovery_info.items(): + ihc_id = device['ihc_id'] + product_cfg = device['product_cfg'] + product = device['product'] + sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info, + product_cfg.get(CONF_TYPE), + product_cfg[CONF_INVERTING], + product) + devices.append(sensor) + else: + binary_sensors = config[CONF_BINARY_SENSORS] + for sensor_cfg in binary_sensors: + ihc_id = sensor_cfg[CONF_ID] + name = sensor_cfg[CONF_NAME] + sensor_type = sensor_cfg.get(CONF_TYPE) + inverting = sensor_cfg[CONF_INVERTING] + sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info, + sensor_type, inverting) + devices.append(sensor) + + add_devices(devices) + + +class IHCBinarySensor(IHCDevice, BinarySensorDevice): + """IHC Binary Sensor. + + The associated IHC resource can be any in or output from a IHC product + or function block, but it must be a boolean ON/OFF resources. + """ + + def __init__(self, ihc_controller, name, ihc_id: int, info: bool, + sensor_type: str, inverting: bool, + product: Element = None) -> None: + """Initialize the IHC binary sensor.""" + super().__init__(ihc_controller, name, ihc_id, info, product) + self._state = None + self._sensor_type = sensor_type + self.inverting = inverting + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._sensor_type + + @property + def is_on(self): + """Return true if the binary sensor is on/open.""" + return self._state + + def on_ihc_change(self, ihc_id, value): + """IHC resource has changed.""" + if self.inverting: + self._state = not value + else: + self._state = value + self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/insteon_plm.py b/homeassistant/components/binary_sensor/insteon_plm.py index 448ceae8636c8..9cb87b317499a 100644 --- a/homeassistant/components/binary_sensor/insteon_plm.py +++ b/homeassistant/components/binary_sensor/insteon_plm.py @@ -2,86 +2,56 @@ Support for INSTEON dimmers via PowerLinc Modem. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/insteon_plm/ +https://home-assistant.io/components/binary_sensor.insteon_plm/ """ -import logging import asyncio +import logging -from homeassistant.core import callback from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.loader import get_component +from homeassistant.components.insteon_plm import InsteonPLMEntity DEPENDENCIES = ['insteon_plm'] _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES = {'openClosedSensor': 'opening', + 'motionSensor': 'motion', + 'doorSensor': 'door', + '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'] - - device_list = [] - for device in discovery_info: - name = device.get('address') - address = device.get('address_hex') + plm = hass.data['insteon_plm'].get('plm') - _LOGGER.info('Registered %s with binary_sensor platform.', name) + 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) - device_list.append( - InsteonPLMBinarySensorDevice(hass, plm, address, name) - ) + new_entity = InsteonPLMBinarySensor(device, state_key) - async_add_devices(device_list) + async_add_devices([new_entity]) -class InsteonPLMBinarySensorDevice(BinarySensorDevice): - """A Class for an Insteon device.""" +class InsteonPLMBinarySensor(InsteonPLMEntity, BinarySensorDevice): + """A Class for an Insteon device entity.""" - def __init__(self, hass, plm, address, name): - """Initialize the binarysensor.""" - self._hass = hass - self._plm = plm.protocol - self._address = address - self._name = name - - self._plm.add_update_callback( - self.async_binarysensor_update, {'address': self._address}) - - @property - def should_poll(self): - """No polling needed.""" - return False + def __init__(self, device, state_key): + """Initialize the INSTEON PLM binary sensor.""" + super().__init__(device, state_key) + self._sensor_type = SENSOR_TYPES.get(self._insteon_device_state.name) @property - def address(self): - """Return the the address of the node.""" - return self._address - - @property - def name(self): - """Return the the name of the node.""" - return self._name + def device_class(self): + """Return the class of this sensor.""" + return self._sensor_type @property def is_on(self): """Return the boolean response if the node is on.""" - sensorstate = self._plm.get_device_attr(self._address, 'sensorstate') - _LOGGER.info("Sensor state for %s is %s", self._address, sensorstate) - return bool(sensorstate) - - @property - def device_state_attributes(self): - """Provide attributes for display on device card.""" - insteon_plm = get_component('insteon_plm') - return insteon_plm.common_attributes(self) - - def get_attr(self, key): - """Return specified attribute for this device.""" - return self._plm.get_device_attr(self.address, key) - - @callback - def async_binarysensor_update(self, message): - """Receive notification from transport that new data exists.""" - _LOGGER.info("Received update calback from PLM for %s", self._address) - self._hass.async_add_job(self.async_update_ha_state()) + return bool(self._insteon_device_state.value) diff --git a/homeassistant/components/binary_sensor/iss.py b/homeassistant/components/binary_sensor/iss.py index 3b927853c009f..d35c36a012e94 100644 --- a/homeassistant/components/binary_sensor/iss.py +++ b/homeassistant/components/binary_sensor/iss.py @@ -13,7 +13,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.binary_sensor import ( BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_NAME, ATTR_LONGITUDE, ATTR_LATITUDE) +from homeassistant.const import ( + CONF_NAME, ATTR_LONGITUDE, ATTR_LATITUDE, CONF_SHOW_ON_MAP) from homeassistant.util import Throttle REQUIREMENTS = ['pyiss==1.0.1'] @@ -23,8 +24,6 @@ ATTR_ISS_NEXT_RISE = 'next_rise' ATTR_ISS_NUMBER_PEOPLE_SPACE = 'number_of_people_in_space' -CONF_SHOW_ON_MAP = 'show_on_map' - DEFAULT_NAME = 'ISS' DEFAULT_DEVICE_CLASS = 'visible' diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index fd6269e363026..fb86244acf318 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -4,67 +4,357 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.isy994/ """ + +import asyncio import logging +from datetime import timedelta from typing import Callable # noqa +from homeassistant.core import callback from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN -import homeassistant.components.isy994 as isy +from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, + ISYDevice) from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -VALUE_TO_STATE = { - False: STATE_OFF, - True: STATE_ON, +ISY_DEVICE_TYPES = { + 'moisture': ['16.8', '16.13', '16.14'], + 'opening': ['16.9', '16.6', '16.7', '16.2', '16.17', '16.20', '16.21'], + 'motion': ['16.1', '16.4', '16.5', '16.3'] } -UOM = ['2', '78'] -STATES = [STATE_OFF, STATE_ON, 'true', 'false'] - # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 binary sensor platform.""" - if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error("A connection has not been made to the ISY controller") - return False - devices = [] + devices_by_nid = {} + child_nodes = [] - for node in isy.filter_nodes(isy.SENSOR_NODES, units=UOM, - states=STATES): - devices.append(ISYBinarySensorDevice(node)) + for node in hass.data[ISY994_NODES][DOMAIN]: + if node.parent_node is None: + device = ISYBinarySensorDevice(node) + devices.append(device) + devices_by_nid[node.nid] = device + else: + # We'll process the child nodes last, to ensure all parent nodes + # have been processed + child_nodes.append(node) - for program in isy.PROGRAMS.get(DOMAIN, []): + for node in child_nodes: try: - status = program[isy.KEY_STATUS] - except (KeyError, AssertionError): - pass + parent_device = devices_by_nid[node.parent_node.nid] + except KeyError: + _LOGGER.error("Node %s has a parent node %s, but no device " + "was created for the parent. Skipping.", + node.nid, node.parent_nid) else: - devices.append(ISYBinarySensorProgram(program.name, status)) + device_type = _detect_device_type(node) + subnode_id = int(node.nid[-1]) + if (device_type == 'opening' or device_type == 'moisture'): + # These sensors use an optional "negative" subnode 2 to snag + # all state changes + if subnode_id == 2: + parent_device.add_negative_node(node) + elif subnode_id == 4: + # Subnode 4 is the heartbeat node, which we will represent + # as a separate binary_sensor + device = ISYBinarySensorHeartbeat(node, parent_device) + parent_device.add_heartbeat_device(device) + devices.append(device) + else: + # We don't yet have any special logic for other sensor types, + # so add the nodes as individual devices + device = ISYBinarySensorDevice(node) + devices.append(device) + + for name, status, _ in hass.data[ISY994_PROGRAMS][DOMAIN]: + devices.append(ISYBinarySensorProgram(name, status)) add_devices(devices) -class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice): - """Representation of an ISY994 binary sensor device.""" +def _detect_device_type(node) -> str: + try: + device_type = node.type + except AttributeError: + # The type attribute didn't exist in the ISY's API response + return None + + split_type = device_type.split('.') + for device_class, ids in ISY_DEVICE_TYPES.items(): + if '{}.{}'.format(split_type[0], split_type[1]) in ids: + return device_class + + return None + + +def _is_val_unknown(val): + """Determine if a number value represents UNKNOWN from PyISY.""" + return val == -1*float('inf') + + +class ISYBinarySensorDevice(ISYDevice, BinarySensorDevice): + """Representation of an ISY994 binary sensor device. + + Often times, a single device is represented by multiple nodes in the ISY, + allowing for different nuances in how those devices report their on and + off events. This class turns those multiple nodes in to a single Hass + entity and handles both ways that ISY binary sensors can work. + """ def __init__(self, node) -> None: """Initialize the ISY994 binary sensor device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) + self._negative_node = None + self._heartbeat_device = None + self._device_class_from_type = _detect_device_type(self._node) + # pylint: disable=protected-access + if _is_val_unknown(self._node.status._val): + self._computed_state = None + else: + self._computed_state = bool(self._node.status._val) + + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node and subnode event emitters.""" + yield from super().async_added_to_hass() + + self._node.controlEvents.subscribe(self._positive_node_control_handler) + + if self._negative_node is not None: + self._negative_node.controlEvents.subscribe( + self._negative_node_control_handler) + + def add_heartbeat_device(self, device) -> None: + """Register a heartbeat device for this sensor. + + The heartbeat node beats on its own, but we can gain a little + reliability by considering any node activity for this sensor + to be a heartbeat as well. + """ + self._heartbeat_device = device + + def _heartbeat(self) -> None: + """Send a heartbeat to our heartbeat device, if we have one.""" + if self._heartbeat_device is not None: + self._heartbeat_device.heartbeat() + + def add_negative_node(self, child) -> None: + """Add a negative node to this binary sensor device. + + The negative node is a node that can receive the 'off' events + for the sensor, depending on device configuration and type. + """ + self._negative_node = child + + # 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 + + def _negative_node_control_handler(self, event: object) -> None: + """Handle an "On" control event from the "negative" node.""" + if event == 'DON': + _LOGGER.debug("Sensor %s turning Off via the Negative node " + "sending a DON command", self.name) + self._computed_state = False + self.schedule_update_ha_state() + self._heartbeat() + + def _positive_node_control_handler(self, event: object) -> None: + """Handle On and Off control event coming from the primary node. + + Depending on device configuration, sometimes only On events + will come to this node, with the negative node representing Off + events + """ + if event == 'DON': + _LOGGER.debug("Sensor %s turning On via the Primary node " + "sending a DON command", self.name) + self._computed_state = True + self.schedule_update_ha_state() + self._heartbeat() + if event == 'DOF': + _LOGGER.debug("Sensor %s turning Off via the Primary node " + "sending a DOF command", self.name) + self._computed_state = False + self.schedule_update_ha_state() + self._heartbeat() + + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Ignore primary node status updates. + + We listen directly to the Control events on all nodes for this + device. + """ + pass + + @property + def value(self) -> object: + """Get the current value of the device. + + Insteon leak sensors set their primary node to On when the state is + DRY, not WET, so we invert the binary state if the user indicates + that it is a moisture sensor. + """ + if self._computed_state is None: + # Do this first so we don't invert None on moisture sensors + return None + + if self.device_class == 'moisture': + return not self._computed_state + + return self._computed_state @property def is_on(self) -> bool: - """Get whether the ISY994 binary sensor device is on.""" + """Get whether the ISY994 binary sensor device is on. + + Note: This method will return false if the current state is UNKNOWN + """ return bool(self.value) + @property + def state(self): + """Return the state of the binary sensor.""" + if self._computed_state is None: + return None + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self) -> str: + """Return the class of this device. + + This was discovered by parsing the device type code during init + """ + return self._device_class_from_type -class ISYBinarySensorProgram(ISYBinarySensorDevice): - """Representation of an ISY994 binary sensor program.""" + +class ISYBinarySensorHeartbeat(ISYDevice, BinarySensorDevice): + """Representation of the battery state of an ISY994 sensor.""" + + def __init__(self, node, parent_device) -> None: + """Initialize the ISY994 binary sensor device.""" + super().__init__(node) + self._computed_state = None + self._parent_device = parent_device + self._heartbeat_timer = None + + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node and subnode event emitters.""" + yield from super().async_added_to_hass() + + self._node.controlEvents.subscribe( + self._heartbeat_node_control_handler) + + # Start the timer on bootup, so we can change from UNKNOWN to ON + self._restart_timer() + + def _heartbeat_node_control_handler(self, event: object) -> None: + """Update the heartbeat timestamp when an On event is sent.""" + if event == 'DON': + self.heartbeat() + + def heartbeat(self): + """Mark the device as online, and restart the 25 hour timer. + + This gets called when the heartbeat node beats, but also when the + parent sensor sends any events, as we can trust that to mean the device + is online. This mitigates the risk of false positives due to a single + missed heartbeat event. + """ + self._computed_state = False + self._restart_timer() + self.schedule_update_ha_state() + + def _restart_timer(self): + """Restart the 25 hour timer.""" + try: + self._heartbeat_timer() + self._heartbeat_timer = None + except TypeError: + # No heartbeat timer is active + pass + + # pylint: disable=unused-argument + @callback + def timer_elapsed(now) -> None: + """Heartbeat missed; set state to indicate dead battery.""" + self._computed_state = True + self._heartbeat_timer = None + self.schedule_update_ha_state() + + point_in_time = dt_util.utcnow() + timedelta(hours=25) + _LOGGER.debug("Timer starting. Now: %s Then: %s", + dt_util.utcnow(), point_in_time) + + self._heartbeat_timer = async_track_point_in_utc_time( + self.hass, timer_elapsed, point_in_time) + + # pylint: disable=unused-argument + def on_update(self, event: object) -> None: + """Ignore node status updates. + + We listen directly to the Control events for this device. + """ + pass + + @property + def value(self) -> object: + """Get the current value of this sensor.""" + return self._computed_state + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on. + + Note: This method will return false if the current state is UNKNOWN + """ + return bool(self.value) + + @property + def state(self): + """Return the state of the binary sensor.""" + if self._computed_state is None: + return None + return STATE_ON if self.is_on else STATE_OFF + + @property + def device_class(self) -> str: + """Get the class of this device.""" + return 'battery' + + @property + def device_state_attributes(self): + """Get the state attributes for the device.""" + attr = super().device_state_attributes + attr['parent_entity_id'] = self._parent_device.entity_id + return attr + + +class ISYBinarySensorProgram(ISYDevice, BinarySensorDevice): + """Representation of an ISY994 binary sensor program. + + This does not need all of the subnode logic in the device version of binary + sensors. + """ def __init__(self, name, node) -> None: """Initialize the ISY994 binary sensor program.""" - ISYBinarySensorDevice.__init__(self, node) + super().__init__(node) self._name = name + + @property + def is_on(self) -> bool: + """Get whether the ISY994 binary sensor device is on.""" + return bool(self.value) diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 2b11c3fe172ee..834186b8b185e 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -4,13 +4,13 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.knx/ """ -import asyncio + import voluptuous as vol -from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES, \ - KNXAutomation -from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, \ - BinarySensorDevice +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.components.knx import ( + ATTR_DISCOVER_DEVICES, DATA_KNX, KNXAutomation) from homeassistant.const import CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -25,6 +25,7 @@ CONF_COUNTER = 'counter' CONF_DEFAULT_COUNTER = 1 CONF_ACTION = 'action' +CONF_RESET_AFTER = 'reset_after' CONF__ACTION = 'turn_off_action' @@ -34,7 +35,7 @@ AUTOMATION_SCHEMA = vol.Schema({ vol.Optional(CONF_HOOK, default=CONF_DEFAULT_HOOK): cv.string, vol.Optional(CONF_COUNTER, default=CONF_DEFAULT_COUNTER): cv.port, - vol.Required(CONF_ACTION, default=None): cv.SCRIPT_SCHEMA + vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA }) AUTOMATIONS_SCHEMA = vol.All( @@ -48,39 +49,33 @@ vol.Optional(CONF_DEVICE_CLASS): cv.string, vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT): cv.positive_int, - vol.Optional(CONF_AUTOMATION, default=None): AUTOMATIONS_SCHEMA, + vol.Optional(CONF_RESET_AFTER): cv.positive_int, + vol.Optional(CONF_AUTOMATION): AUTOMATIONS_SCHEMA, }) -@asyncio.coroutine -def async_setup_platform(hass, config, add_devices, - discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up binary sensor(s) for KNX platform.""" - if DATA_KNX not in hass.data \ - or not hass.data[DATA_KNX].initialized: - return False - if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) - - return True + async_add_devices_config(hass, config, async_add_devices) @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up binary sensors for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXBinarySensor(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): - """Set up binary senor for KNX platform configured within plattform.""" +def async_add_devices_config(hass, config, async_add_devices): + """Set up binary senor for KNX platform configured within platform.""" name = config.get(CONF_NAME) import xknx binary_sensor = xknx.devices.BinarySensor( @@ -88,7 +83,8 @@ def async_add_devices_config(hass, config, add_devices): name=name, group_address=config.get(CONF_ADDRESS), device_class=config.get(CONF_DEVICE_CLASS), - significant_bit=config.get(CONF_SIGNIFICANT_BIT)) + significant_bit=config.get(CONF_SIGNIFICANT_BIT), + reset_after=config.get(CONF_RESET_AFTER)) hass.data[DATA_KNX].xknx.devices.add(binary_sensor) entity = KNXBinarySensor(hass, binary_sensor) @@ -101,14 +97,14 @@ def async_add_devices_config(hass, config, add_devices): entity.automations.append(KNXAutomation( hass=hass, device=binary_sensor, hook=hook, action=action, counter=counter)) - add_devices([entity]) + async_add_devices([entity]) class KNXBinarySensor(BinarySensorDevice): """Representation of a KNX binary sensor.""" def __init__(self, hass, device): - """Initialization of KNXBinarySensor.""" + """Initialize of KNX binary sensor.""" self.device = device self.hass = hass self.async_register_callbacks() @@ -117,11 +113,10 @@ def __init__(self, hass, device): @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): - """Callback after device was updated.""" + async def after_update_callback(device): + """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property @@ -129,6 +124,11 @@ def name(self): """Return the name of the KNX device.""" return self.device.name + @property + def available(self): + """Return True if entity is available.""" + return self.hass.data[DATA_KNX].connected + @property def should_poll(self): """No polling needed within KNX.""" diff --git a/homeassistant/components/binary_sensor/konnected.py b/homeassistant/components/binary_sensor/konnected.py new file mode 100644 index 0000000000000..c7e2b7c84fe4d --- /dev/null +++ b/homeassistant/components/binary_sensor/konnected.py @@ -0,0 +1,74 @@ +""" +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 asyncio +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.konnected import (DOMAIN, PIN_TO_ZONE) +from homeassistant.const import ( + CONF_DEVICES, CONF_TYPE, CONF_NAME, CONF_BINARY_SENSORS, ATTR_STATE) + +_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[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])) + self._data['entity'] = self + _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 + + @asyncio.coroutine + def async_set_state(self, state): + """Update the sensor's state.""" + self._state = state + self._data[ATTR_STATE] = state + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/linode.py b/homeassistant/components/binary_sensor/linode.py new file mode 100644 index 0000000000000..8af0318373d5a --- /dev/null +++ b/homeassistant/components/binary_sensor/linode.py @@ -0,0 +1,96 @@ +""" +Support for monitoring the state of Linode Nodes. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.linode/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.linode import ( + CONF_NODES, ATTR_CREATED, ATTR_NODE_ID, ATTR_NODE_NAME, + ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, + ATTR_REGION, ATTR_VCPUS, DATA_LINODE) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Node' +DEFAULT_DEVICE_CLASS = 'moving' +DEPENDENCIES = ['linode'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NODES): vol.All(cv.ensure_list, [cv.string]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Linode droplet sensor.""" + linode = hass.data.get(DATA_LINODE) + nodes = config.get(CONF_NODES) + + dev = [] + for node in nodes: + node_id = linode.get_node_id(node) + if node_id is None: + _LOGGER.error("Node %s is not available", node) + return + dev.append(LinodeBinarySensor(linode, node_id)) + + add_devices(dev, True) + + +class LinodeBinarySensor(BinarySensorDevice): + """Representation of a Linode droplet sensor.""" + + def __init__(self, li, node_id): + """Initialize a new Linode sensor.""" + self._linode = li + self._node_id = node_id + self._state = None + self.data = None + + @property + def name(self): + """Return the name of the sensor.""" + if self.data is not None: + return self.data.label + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + if self.data is not None: + return self.data.status == 'running' + return False + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEFAULT_DEVICE_CLASS + + @property + def device_state_attributes(self): + """Return the state attributes of the Linode Node.""" + if self.data: + return { + ATTR_CREATED: self.data.created, + ATTR_NODE_ID: self.data.id, + ATTR_NODE_NAME: self.data.label, + ATTR_IPV4_ADDRESS: self.data.ipv4, + ATTR_IPV6_ADDRESS: self.data.ipv6, + ATTR_MEMORY: self.data.specs.memory, + ATTR_REGION: self.data.region.country, + ATTR_VCPUS: self.data.specs.vcpus, + } + return {} + + def update(self): + """Update state of sensor.""" + self._linode.update() + if self._linode.data is not None: + for node in self._linode.data: + if node.id == self._node_id: + self.data = node diff --git a/homeassistant/components/binary_sensor/maxcube.py b/homeassistant/components/binary_sensor/maxcube.py index cf2be6baed5f0..c131de5420a5e 100644 --- a/homeassistant/components/binary_sensor/maxcube.py +++ b/homeassistant/components/binary_sensor/maxcube.py @@ -7,7 +7,7 @@ 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 @@ 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 = 'opening' + 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/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index 3702b32d5865a..e033355f65531 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -14,16 +14,21 @@ from homeassistant.components.binary_sensor import ( BinarySensorDevice, DEVICE_CLASSES_SCHEMA) from homeassistant.const import ( - CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF, - CONF_DEVICE_CLASS) -from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS) + CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, + CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS) +from homeassistant.components.mqtt import ( + CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, MqttAvailability) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'MQTT Binary sensor' + DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_PAYLOAD_ON = 'ON' +DEFAULT_FORCE_UPDATE = False + DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ @@ -31,7 +36,8 @@ vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, -}) + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine @@ -47,37 +53,45 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): async_add_devices([MqttBinarySensor( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), + config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_DEVICE_CLASS), config.get(CONF_QOS), + config.get(CONF_FORCE_UPDATE), config.get(CONF_PAYLOAD_ON), config.get(CONF_PAYLOAD_OFF), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), value_template )]) -class MqttBinarySensor(BinarySensorDevice): +class MqttBinarySensor(MqttAvailability, BinarySensorDevice): """Representation a binary sensor that is updated by MQTT.""" - def __init__(self, name, state_topic, device_class, qos, payload_on, - payload_off, value_template): + def __init__(self, name, state_topic, availability_topic, device_class, + qos, force_update, payload_on, payload_off, payload_available, + payload_not_available, value_template): """Initialize the MQTT binary sensor.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._name = name - self._state = False + self._state = None self._state_topic = state_topic self._device_class = device_class self._payload_on = payload_on self._payload_off = payload_off self._qos = qos + self._force_update = force_update self._template = value_template + @asyncio.coroutine def async_added_to_hass(self): - """Subscribe mqtt events. + """Subscribe mqtt events.""" + yield from super().async_added_to_hass() - This method must be run in the event loop and returns a coroutine. - """ @callback - def message_received(topic, payload, qos): - """Handle a new received MQTT message.""" + def state_message_received(topic, payload, qos): + """Handle a new received MQTT state message.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) @@ -85,11 +99,16 @@ def message_received(topic, payload, qos): self._state = True elif payload == self._payload_off: self._state = False + else: # Payload is not for this entity + _LOGGER.warning('No matching payload found' + ' for entity: %s with state_topic: %s', + self._name, self._state_topic) + return - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() - return mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + yield from mqtt.async_subscribe( + self.hass, self._state_topic, state_message_received, self._qos) @property def should_poll(self): @@ -110,3 +129,8 @@ def is_on(self): def device_class(self): """Return the class of this sensor.""" return self._device_class + + @property + def force_update(self): + """Force update.""" + return self._force_update diff --git a/homeassistant/components/binary_sensor/mychevy.py b/homeassistant/components/binary_sensor/mychevy.py new file mode 100644 index 0000000000000..a89395ed86f10 --- /dev/null +++ b/homeassistant/components/binary_sensor/mychevy.py @@ -0,0 +1,85 @@ +"""Support for MyChevy sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.mychevy/ +""" + +import asyncio +import logging + +from homeassistant.components.mychevy import ( + EVBinarySensorConfig, DOMAIN as MYCHEVY_DOMAIN, UPDATE_TOPIC +) +from homeassistant.components.binary_sensor import ( + ENTITY_ID_FORMAT, BinarySensorDevice) +from homeassistant.core import callback +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +SENSORS = [ + EVBinarySensorConfig("Plugged In", "plugged_in", "plug") +] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the MyChevy sensors.""" + if discovery_info is None: + return + + sensors = [] + hub = hass.data[MYCHEVY_DOMAIN] + for sconfig in SENSORS: + sensors.append(EVBinarySensor(hub, sconfig)) + + async_add_devices(sensors) + + +class EVBinarySensor(BinarySensorDevice): + """Base EVSensor class. + + The only real difference between sensors is which units and what + attribute from the car object they are returning. All logic can be + built with just setting subclass attributes. + + """ + + def __init__(self, connection, config): + """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.entity_id = ENTITY_ID_FORMAT.format( + '{}_{}'.format(MYCHEVY_DOMAIN, slugify(self._name))) + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def is_on(self): + """Return if on.""" + return self._is_on + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + UPDATE_TOPIC, self.async_update_callback) + + @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) + self.async_schedule_update_ha_state() + + @property + def should_poll(self): + """Return the polling state.""" + return False diff --git a/homeassistant/components/binary_sensor/mysensors.py b/homeassistant/components/binary_sensor/mysensors.py index 4b83f0c8f2df1..214430211932e 100644 --- a/homeassistant/components/binary_sensor/mysensors.py +++ b/homeassistant/components/binary_sensor/mysensors.py @@ -5,21 +5,32 @@ https://home-assistant.io/components/binary_sensor.mysensors/ """ from homeassistant.components import mysensors -from homeassistant.components.binary_sensor import (DEVICE_CLASSES, DOMAIN, - BinarySensorDevice) +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): - """Setup 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): - """Represent the value of a MySensors Binary Sensor child node.""" +class MySensorsBinarySensor(mysensors.MySensorsEntity, BinarySensorDevice): + """Representation of a MySensors Binary Sensor child node.""" @property def is_on(self): @@ -30,18 +41,7 @@ def is_on(self): 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/mystrom.py b/homeassistant/components/binary_sensor/mystrom.py index 08ab1f4a8b738..93d56a97c4273 100644 --- a/homeassistant/components/binary_sensor/mystrom.py +++ b/homeassistant/components/binary_sensor/mystrom.py @@ -7,7 +7,7 @@ import asyncio import logging -from homeassistant.components.binary_sensor import (BinarySensorDevice, DOMAIN) +from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice from homeassistant.components.http import HomeAssistantView from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY @@ -37,7 +37,7 @@ def __init__(self, add_devices): @asyncio.coroutine def get(self, request): - """The GET request received from a myStrom button.""" + """Handle the GET request received from a myStrom button.""" res = yield from self._handle(request.app['hass'], request.query) return res @@ -92,4 +92,4 @@ def is_on(self): def async_on_update(self, value): """Receive an update.""" self._state = value - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/netatmo.py b/homeassistant/components/binary_sensor/netatmo.py index 13b9fc1f0051e..fd0e30ccebc40 100644 --- a/homeassistant/components/binary_sensor/netatmo.py +++ b/homeassistant/components/binary_sensor/netatmo.py @@ -13,8 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDevice, PLATFORM_SCHEMA) from homeassistant.components.netatmo import CameraData -from homeassistant.loader import get_component -from homeassistant.const import CONF_TIMEOUT, CONF_OFFSET +from homeassistant.const import CONF_TIMEOUT from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -44,18 +43,16 @@ CONF_PRESENCE_SENSORS = 'presence_sensors' CONF_TAG_SENSORS = 'tag_sensors' -DEFAULT_TIMEOUT = 15 -DEFAULT_OFFSET = 90 +DEFAULT_TIMEOUT = 90 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_CAMERAS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_HOME): cv.string, - vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): cv.positive_int, - vol.Optional(CONF_PRESENCE_SENSORS, default=PRESENCE_SENSOR_TYPES): + vol.Optional(CONF_PRESENCE_SENSORS, default=list(PRESENCE_SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(PRESENCE_SENSOR_TYPES)]), vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_WELCOME_SENSORS, default=WELCOME_SENSOR_TYPES): + vol.Optional(CONF_WELCOME_SENSORS, default=list(WELCOME_SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(WELCOME_SENSOR_TYPES)]), }) @@ -63,10 +60,11 @@ # 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) - offset = config.get(CONF_OFFSET) + if timeout is None: + timeout = DEFAULT_TIMEOUT module_name = None @@ -94,7 +92,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for variable in welcome_sensors: add_devices([NetatmoBinarySensor( data, camera_name, module_name, home, timeout, - offset, camera_type, variable)], True) + camera_type, variable)], True) if camera_type == 'NOC': if CONF_CAMERAS in config: if config[CONF_CAMERAS] != [] and \ @@ -102,14 +100,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): continue for variable in presence_sensors: add_devices([NetatmoBinarySensor( - data, camera_name, module_name, home, timeout, offset, + data, camera_name, module_name, home, timeout, camera_type, variable)], True) for module_name in data.get_module_names(camera_name): for variable in tag_sensors: camera_type = None add_devices([NetatmoBinarySensor( - data, camera_name, module_name, home, timeout, offset, + data, camera_name, module_name, home, timeout, camera_type, variable)], True) @@ -117,14 +115,13 @@ class NetatmoBinarySensor(BinarySensorDevice): """Represent a single binary sensor in a Netatmo Camera device.""" def __init__(self, data, camera_name, module_name, home, - timeout, offset, camera_type, sensor): + timeout, camera_type, sensor): """Set up for access to the Netatmo camera events.""" self._data = data self._camera_name = camera_name self._module_name = module_name self._home = home self._timeout = timeout - self._offset = offset if home: self._name = '{} / {}'.format(home, camera_name) else: @@ -133,10 +130,6 @@ def __init__(self, data, camera_name, module_name, home, self._name += ' / ' + module_name self._sensor_name = sensor self._name += ' ' + sensor - camera_id = data.camera_data.cameraByName( - camera=camera_name, home=home)['id'] - self._unique_id = "Netatmo_binary_sensor {0} - {1}".format( - self._name, camera_id) self._cameratype = camera_type self._state = None @@ -145,11 +138,6 @@ def name(self): """Return the name of the Netatmo device and this sensor.""" return self._name - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return self._unique_id - @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" @@ -173,40 +161,39 @@ def update(self): if self._sensor_name == "Someone known": self._state =\ self._data.camera_data.someoneKnownSeen( - self._home, self._camera_name, self._timeout*60) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Someone unknown": self._state =\ self._data.camera_data.someoneUnknownSeen( - self._home, self._camera_name, self._timeout*60) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Motion": self._state =\ self._data.camera_data.motionDetected( - self._home, self._camera_name, self._timeout*60) + self._home, self._camera_name, self._timeout) elif self._cameratype == 'NOC': if self._sensor_name == "Outdoor motion": self._state =\ self._data.camera_data.outdoormotionDetected( - self._home, self._camera_name, self._offset) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Outdoor human": self._state =\ self._data.camera_data.humanDetected( - self._home, self._camera_name, self._offset) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Outdoor animal": self._state =\ self._data.camera_data.animalDetected( - self._home, self._camera_name, self._offset) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Outdoor vehicle": self._state =\ self._data.camera_data.carDetected( - self._home, self._camera_name, self._offset) + self._home, self._camera_name, self._timeout) if self._sensor_name == "Tag Vibration": self._state =\ self._data.camera_data.moduleMotionDetected( self._home, self._module_name, self._camera_name, - self._timeout*60) + self._timeout) elif self._sensor_name == "Tag Open": self._state =\ self._data.camera_data.moduleOpened( - self._home, self._module_name, self._camera_name) - else: - return None + self._home, self._module_name, self._camera_name, + self._timeout) diff --git a/homeassistant/components/binary_sensor/octoprint.py b/homeassistant/components/binary_sensor/octoprint.py index 129b5250431fe..265fcec66fa9c 100644 --- a/homeassistant/components/binary_sensor/octoprint.py +++ b/homeassistant/components/binary_sensor/octoprint.py @@ -27,7 +27,7 @@ } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + 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, }) diff --git a/homeassistant/components/binary_sensor/pilight.py b/homeassistant/components/binary_sensor/pilight.py index c4c26d3a12216..d2c46c795a853 100644 --- a/homeassistant/components/binary_sensor/pilight.py +++ b/homeassistant/components/binary_sensor/pilight.py @@ -97,7 +97,7 @@ def is_on(self): def _handle_code(self, call): """Handle received code by the pilight-daemon. - If the code matches the defined playload + If the code matches the defined payload of this sensor the sensor state is changed accordingly. """ # Check if received code matches defined playoad @@ -162,10 +162,10 @@ def _reset_state(self, call): def _handle_code(self, call): """Handle received code by the pilight-daemon. - If the code matches the defined playload + If the code matches the defined payload of this sensor the sensor state is changed accordingly. """ - # Check if received code matches defined playoad + # Check if received code matches defined payload # True if payload is contained in received code dict payload_ok = True for key in self._payload: diff --git a/homeassistant/components/binary_sensor/qwikswitch.py b/homeassistant/components/binary_sensor/qwikswitch.py new file mode 100644 index 0000000000000..067021b0c7a84 --- /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/raincloud.py b/homeassistant/components/binary_sensor/raincloud.py new file mode 100644 index 0000000000000..288b46c237094 --- /dev/null +++ b/homeassistant/components/binary_sensor/raincloud.py @@ -0,0 +1,72 @@ +""" +Support for Melnor RainCloud sprinkler water timer. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.raincloud/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.raincloud import ( + BINARY_SENSORS, DATA_RAINCLOUD, ICON_MAP, RainCloudEntity) +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import CONF_MONITORED_CONDITIONS + +DEPENDENCIES = ['raincloud'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(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 raincloud device.""" + raincloud = hass.data[DATA_RAINCLOUD].data + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + if sensor_type == 'status': + sensors.append( + RainCloudBinarySensor(raincloud.controller, sensor_type)) + sensors.append( + RainCloudBinarySensor(raincloud.controller.faucet, + sensor_type)) + + else: + # create a sensor for each zone managed by faucet + for zone in raincloud.controller.faucet.zones: + sensors.append(RainCloudBinarySensor(zone, sensor_type)) + + add_devices(sensors, True) + return True + + +class RainCloudBinarySensor(RainCloudEntity, BinarySensorDevice): + """A sensor implementation for raincloud 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 RainCloud sensor: %s", self._name) + self._state = getattr(self.data, self._sensor_type) + if self._sensor_type == 'status': + self._state = self._state == 'Online' + + @property + def icon(self): + """Return the icon of this device.""" + if self._sensor_type == 'is_watering': + return 'mdi:water' if self.is_on else 'mdi:water-off' + elif self._sensor_type == 'status': + return 'mdi:pipe' if self.is_on else 'mdi:pipe-disconnected' + return ICON_MAP.get(self._sensor_type) diff --git a/homeassistant/components/binary_sensor/random.py b/homeassistant/components/binary_sensor/random.py new file mode 100644 index 0000000000000..162d0480389c0 --- /dev/null +++ b/homeassistant/components/binary_sensor/random.py @@ -0,0 +1,64 @@ +""" +Support for showing random states. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.random/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) +from homeassistant.const import CONF_NAME, CONF_DEVICE_CLASS + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Random Binary Sensor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Random binary sensor.""" + name = config.get(CONF_NAME) + device_class = config.get(CONF_DEVICE_CLASS) + + async_add_devices([RandomSensor(name, device_class)], True) + + +class RandomSensor(BinarySensorDevice): + """Representation of a Random binary sensor.""" + + def __init__(self, name, device_class): + """Initialize the Random binary sensor.""" + self._name = name + self._device_class = device_class + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the sensor class of the sensor.""" + return self._device_class + + @asyncio.coroutine + def async_update(self): + """Get new state and update the sensor's state.""" + from random import getrandbits + self._state = bool(getrandbits(1)) diff --git a/homeassistant/components/binary_sensor/raspihats.py b/homeassistant/components/binary_sensor/raspihats.py index ad19fb525a128..9d489a59711a3 100644 --- a/homeassistant/components/binary_sensor/raspihats.py +++ b/homeassistant/components/binary_sensor/raspihats.py @@ -5,18 +5,17 @@ https://home-assistant.io/components/binary_sensor.raspihats/ """ import logging + import voluptuous as vol -from homeassistant.const import ( - CONF_NAME, CONF_DEVICE_CLASS, DEVICE_DEFAULT_NAME -) -import homeassistant.helpers.config_validation as cv + from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, BinarySensorDevice -) + PLATFORM_SCHEMA, BinarySensorDevice) from homeassistant.components.raspihats import ( - CONF_I2C_HATS, CONF_BOARD, CONF_ADDRESS, CONF_CHANNELS, CONF_INDEX, - CONF_INVERT_LOGIC, I2C_HAT_NAMES, I2C_HATS_MANAGER, I2CHatsException -) + CONF_ADDRESS, CONF_BOARD, CONF_CHANNELS, CONF_I2C_HATS, CONF_INDEX, + CONF_INVERT_LOGIC, I2C_HAT_NAMES, I2C_HATS_MANAGER, I2CHatsException) +from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, DEVICE_DEFAULT_NAME) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -45,7 +44,7 @@ # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the raspihats binary_sensor devices.""" + """Set up the raspihats binary_sensor devices.""" I2CHatBinarySensor.I2C_HATS_MANAGER = hass.data[I2C_HATS_MANAGER] binary_sensors = [] i2c_hat_configs = config.get(CONF_I2C_HATS) @@ -65,39 +64,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None): ) ) except I2CHatsException as ex: - _LOGGER.error( - "Failed to register " + board + "I2CHat@" + hex(address) + " " - + str(ex) - ) + _LOGGER.error("Failed to register %s I2CHat@%s %s", + board, hex(address), str(ex)) add_devices(binary_sensors) class I2CHatBinarySensor(BinarySensorDevice): - """Represents a binary sensor that uses a I2C-HAT digital input.""" + """Representation of a binary sensor that uses a I2C-HAT digital input.""" I2C_HATS_MANAGER = None def __init__(self, address, channel, name, invert_logic, device_class): - """Initialize sensor.""" + """Initialize the raspihats sensor.""" self._address = address self._channel = channel self._name = name or DEVICE_DEFAULT_NAME self._invert_logic = invert_logic self._device_class = device_class self._state = self.I2C_HATS_MANAGER.read_di( - self._address, - self._channel - ) + self._address, self._channel) def online_callback(): - """Callback fired when board is online.""" + """Call fired when board is online.""" self.schedule_update_ha_state() self.I2C_HATS_MANAGER.register_online_callback( - self._address, - self._channel, - online_callback - ) + self._address, self._channel, online_callback) def edge_callback(state): """Read digital input state.""" @@ -105,10 +97,7 @@ def edge_callback(state): self.schedule_update_ha_state() self.I2C_HATS_MANAGER.register_di_callback( - self._address, - self._channel, - edge_callback - ) + self._address, self._channel, edge_callback) @property def device_class(self): @@ -122,7 +111,7 @@ def name(self): @property def should_poll(self): - """Polling not needed for this sensor.""" + """No polling needed for this sensor.""" return False @property diff --git a/homeassistant/components/binary_sensor/rest.py b/homeassistant/components/binary_sensor/rest.py index 1f8d0ebe2f774..e9cb40f674789 100644 --- a/homeassistant/components/binary_sensor/rest.py +++ b/homeassistant/components/binary_sensor/rest.py @@ -98,6 +98,11 @@ def device_class(self): """Return the class of this sensor.""" return self._device_class + @property + def available(self): + """Return the availability of this sensor.""" + return self.rest.data is not None + @property def is_on(self): """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/binary_sensor/rfxtrx.py b/homeassistant/components/binary_sensor/rfxtrx.py index e86c948e1917a..6ac604a4f1ebb 100644 --- a/homeassistant/components/binary_sensor/rfxtrx.py +++ b/homeassistant/components/binary_sensor/rfxtrx.py @@ -1,76 +1,80 @@ """ Support for RFXtrx binary sensors. -Lighting4 devices (sensors based on PT2262 encoder) are supported and -tested. Other types may need some work. - +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.rfxtrx/ """ - import logging + import voluptuous as vol + from homeassistant.components import rfxtrx -from homeassistant.util import slugify -from homeassistant.util import dt as dt_util -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import event as evt -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) from homeassistant.components.rfxtrx import ( - ATTR_AUTOMATIC_ADD, ATTR_NAME, ATTR_OFF_DELAY, ATTR_FIREEVENT, - ATTR_DATA_BITS, CONF_DEVICES -) + ATTR_NAME, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, CONF_DEVICES, + CONF_FIRE_EVENT, CONF_OFF_DELAY) from homeassistant.const import ( - CONF_DEVICE_CLASS, CONF_COMMAND_ON, CONF_COMMAND_OFF -) - -DEPENDENCIES = ["rfxtrx"] + CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_DEVICE_CLASS, CONF_NAME) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import event as evt +from homeassistant.util import dt as dt_util +from homeassistant.util import slugify _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = vol.Schema({ - vol.Required("platform"): rfxtrx.DOMAIN, - vol.Optional(CONF_DEVICES, default={}): vol.All( - dict, rfxtrx.valid_binary_sensor), - vol.Optional(ATTR_AUTOMATIC_ADD, default=False): cv.boolean, +DEPENDENCIES = ['rfxtrx'] + +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_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_OFF_DELAY): + vol.Any(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_DATA_BITS): cv.positive_int, + vol.Optional(CONF_COMMAND_ON): cv.byte, + vol.Optional(CONF_COMMAND_OFF): cv.byte + }) + }, + vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, }, extra=vol.ALLOW_EXTRA) -def setup_platform(hass, config, add_devices_callback, discovery_info=None): - """Setup the Binary Sensor platform to rfxtrx.""" +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Binary Sensor platform to RFXtrx.""" import RFXtrx as rfxtrxmod sensors = [] - for packet_id, entity in config['devices'].items(): + for packet_id, entity in config[CONF_DEVICES].items(): event = rfxtrx.get_rfx_object(packet_id) device_id = slugify(event.device.id_string.lower()) if device_id in rfxtrx.RFX_DEVICES: continue - if entity[ATTR_DATA_BITS] is not None: - _LOGGER.info("Masked device id: %s", - rfxtrx.get_pt2262_deviceid(device_id, - entity[ATTR_DATA_BITS])) - - _LOGGER.info("Add %s rfxtrx.binary_sensor (class %s)", - entity[ATTR_NAME], entity[CONF_DEVICE_CLASS]) - - device = RfxtrxBinarySensor(event, entity[ATTR_NAME], - entity[CONF_DEVICE_CLASS], - entity[ATTR_FIREEVENT], - entity[ATTR_OFF_DELAY], - entity[ATTR_DATA_BITS], - entity[CONF_COMMAND_ON], - entity[CONF_COMMAND_OFF]) + if entity.get(CONF_DATA_BITS) is not None: + _LOGGER.debug( + "Masked device id: %s", rfxtrx.get_pt2262_deviceid( + device_id, entity.get(CONF_DATA_BITS))) + + _LOGGER.debug("Add %s rfxtrx.binary_sensor (class %s)", + entity[ATTR_NAME], entity.get(CONF_DEVICE_CLASS)) + + device = RfxtrxBinarySensor( + event, entity.get(CONF_NAME), entity.get(CONF_DEVICE_CLASS), + entity[CONF_FIRE_EVENT], entity.get(CONF_OFF_DELAY), + entity.get(CONF_DATA_BITS), entity.get(CONF_COMMAND_ON), + entity.get(CONF_COMMAND_OFF)) device.hass = hass - device.is_lighting4 = (packet_id[2:4] == '13') sensors.append(device) rfxtrx.RFX_DEVICES[device_id] = device - add_devices_callback(sensors) + add_devices(sensors) - # pylint: disable=too-many-branches def binary_sensor_update(event): - """Callback for control updates from the RFXtrx gateway.""" + """Call for control updates from the RFXtrx gateway.""" if not isinstance(event, rfxtrxmod.ControlEvent): return @@ -83,37 +87,34 @@ def binary_sensor_update(event): if sensor is None: # Add the entity if not exists and automatic_add is True - if not config[ATTR_AUTOMATIC_ADD]: + if not config[CONF_AUTOMATIC_ADD]: return - poss_dev = rfxtrx.find_possible_pt2262_device(device_id) - - if poss_dev is not None: - poss_id = slugify(poss_dev.event.device.id_string.lower()) - _LOGGER.info("Found possible matching deviceid %s.", - poss_id) + if event.device.packettype == 0x13: + poss_dev = rfxtrx.find_possible_pt2262_device(device_id) + if poss_dev is not None: + poss_id = slugify(poss_dev.event.device.id_string.lower()) + _LOGGER.debug( + "Found possible matching device ID: %s", poss_id) pkt_id = "".join("{0:02x}".format(x) for x in event.data) sensor = RfxtrxBinarySensor(event, pkt_id) sensor.hass = hass - sensor.is_lighting4 = (pkt_id[2:4] == '13') rfxtrx.RFX_DEVICES[device_id] = sensor - add_devices_callback([sensor]) - _LOGGER.info("Added binary sensor %s " - "(Device_id: %s Class: %s Sub: %s)", - pkt_id, - slugify(event.device.id_string.lower()), - event.device.__class__.__name__, - event.device.subtype) + add_devices([sensor]) + _LOGGER.info( + "Added binary sensor %s (Device ID: %s Class: %s Sub: %s)", + pkt_id, slugify(event.device.id_string.lower()), + event.device.__class__.__name__, event.device.subtype) elif not isinstance(sensor, RfxtrxBinarySensor): return else: - _LOGGER.info("Binary sensor update " - "(Device_id: %s Class: %s Sub: %s)", - slugify(event.device.id_string.lower()), - event.device.__class__.__name__, - event.device.subtype) + _LOGGER.debug( + "Binary sensor update (Device ID: %s Class: %s Sub: %s)", + slugify(event.device.id_string.lower()), + event.device.__class__.__name__, event.device.subtype) + if sensor.is_lighting4: if sensor.data_bits is not None: cmd = rfxtrx.get_pt2262_cmd(device_id, sensor.data_bits) @@ -132,29 +133,27 @@ def off_delay_listener(now): sensor.update_state(False) sensor.delay_listener = evt.track_point_in_time( - hass, off_delay_listener, dt_util.utcnow() + sensor.off_delay - ) + hass, off_delay_listener, dt_util.utcnow() + sensor.off_delay) - # Subscribe to main rfxtrx events + # Subscribe to main RFXtrx events if binary_sensor_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS: rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(binary_sensor_update) -# pylint: disable=too-many-instance-attributes,too-many-arguments class RfxtrxBinarySensor(BinarySensorDevice): - """An Rfxtrx binary sensor.""" + """A representation of a RFXtrx binary sensor.""" def __init__(self, event, name, device_class=None, should_fire=False, off_delay=None, data_bits=None, cmd_on=None, cmd_off=None): - """Initialize the sensor.""" + """Initialize the RFXtrx sensor.""" self.event = event self._name = name self._should_fire_event = should_fire self._device_class = device_class self._off_delay = off_delay self._state = False - self.is_lighting4 = False + self.is_lighting4 = (event.device.packettype == 0x13) self.delay_listener = None self._data_bits = data_bits self._cmd_on = cmd_on @@ -162,12 +161,9 @@ def __init__(self, event, name, device_class=None, if data_bits is not None: self._masked_id = rfxtrx.get_pt2262_deviceid( - event.device.id_string.lower(), - data_bits) - - def __str__(self): - """Return the name of the sensor.""" - return self._name + event.device.id_string.lower(), data_bits) + else: + self._masked_id = None @property def name(self): diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index 5c9a644f6b78d..e84009301ab75 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -11,7 +11,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.ring import ( - CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE) + CONF_ATTRIBUTION, DEFAULT_ENTITY_NAMESPACE, DATA_RING) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS) @@ -28,20 +28,20 @@ # Sensor types: Name, category, device_class SENSOR_TYPES = { 'ding': ['Ding', ['doorbell'], 'occupancy'], - 'motion': ['Motion', ['doorbell'], 'motion'], + 'motion': ['Motion', ['doorbell', 'stickup_cams'], 'motion'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for a Ring device.""" - ring = hass.data.get('ring') + ring = hass.data[DATA_RING] sensors = [] for sensor_type in config.get(CONF_MONITORED_CONDITIONS): @@ -50,6 +50,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensors.append(RingBinarySensor(hass, device, sensor_type)) + + for device in ring.stickup_cams: + if 'stickup_cams' in SENSOR_TYPES[sensor_type][1]: + sensors.append(RingBinarySensor(hass, + device, + sensor_type)) add_devices(sensors, True) return True diff --git a/homeassistant/components/binary_sensor/rpi_pfio.py b/homeassistant/components/binary_sensor/rpi_pfio.py index 92d02067dfc6f..1abfa25c82b50 100644 --- a/homeassistant/components/binary_sensor/rpi_pfio.py +++ b/homeassistant/components/binary_sensor/rpi_pfio.py @@ -8,18 +8,17 @@ import voluptuous as vol -import homeassistant.components.rpi_pfio as rpi_pfio from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import DEVICE_DEFAULT_NAME + PLATFORM_SCHEMA, BinarySensorDevice) +import homeassistant.components.rpi_pfio as rpi_pfio +from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -ATTR_NAME = 'name' -ATTR_INVERT_LOGIC = 'invert_logic' -ATTR_SETTLE_TIME = 'settle_time' +CONF_INVERT_LOGIC = 'invert_logic' CONF_PORTS = 'ports' +CONF_SETTLE_TIME = 'settle_time' DEFAULT_INVERT_LOGIC = False DEFAULT_SETTLE_TIME = 20 @@ -27,27 +26,27 @@ DEPENDENCIES = ['rpi_pfio'] PORT_SCHEMA = vol.Schema({ - vol.Optional(ATTR_NAME, default=None): cv.string, - vol.Optional(ATTR_SETTLE_TIME, default=DEFAULT_SETTLE_TIME): + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_SETTLE_TIME, default=DEFAULT_SETTLE_TIME): cv.positive_int, - vol.Optional(ATTR_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean + vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PORTS, default={}): vol.Schema({ - cv.positive_int: PORT_SCHEMA + cv.positive_int: PORT_SCHEMA, }) }) def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the PiFace Digital Input devices.""" + """Set up the PiFace Digital Input devices.""" binary_sensors = [] - ports = config.get('ports') + ports = config.get(CONF_PORTS) for port, port_entity in ports.items(): - name = port_entity[ATTR_NAME] - settle_time = port_entity[ATTR_SETTLE_TIME] / 1000 - invert_logic = port_entity[ATTR_INVERT_LOGIC] + name = port_entity.get(CONF_NAME) + settle_time = port_entity[CONF_SETTLE_TIME] / 1000 + invert_logic = port_entity[CONF_INVERT_LOGIC] binary_sensors.append(RPiPFIOBinarySensor( hass, port, name, settle_time, invert_logic)) diff --git a/homeassistant/components/binary_sensor/satel_integra.py b/homeassistant/components/binary_sensor/satel_integra.py new file mode 100644 index 0000000000000..f373809f7c062 --- /dev/null +++ b/homeassistant/components/binary_sensor/satel_integra.py @@ -0,0 +1,90 @@ +""" +Support for Satel Integra zone states- represented as binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.satel_integra/ +""" +import asyncio +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.satel_integra import (CONF_ZONES, + CONF_ZONE_NAME, + CONF_ZONE_TYPE, + SIGNAL_ZONES_UPDATED) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['satel_integra'] + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Satel Integra binary sensor devices.""" + if not discovery_info: + return + + configured_zones = discovery_info[CONF_ZONES] + + devices = [] + + for zone_num, device_config_data in configured_zones.items(): + zone_type = device_config_data[CONF_ZONE_TYPE] + zone_name = device_config_data[CONF_ZONE_NAME] + device = SatelIntegraBinarySensor(zone_num, zone_name, zone_type) + devices.append(device) + + async_add_devices(devices) + + +class SatelIntegraBinarySensor(BinarySensorDevice): + """Representation of an Satel Integra binary sensor.""" + + def __init__(self, zone_number, zone_name, zone_type): + """Initialize the binary_sensor.""" + self._zone_number = zone_number + self._name = zone_name + self._zone_type = zone_type + self._state = 0 + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_ZONES_UPDATED, self._zones_updated) + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def icon(self): + """Icon for device by its type.""" + if self._zone_type == 'smoke': + return "mdi:fire" + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state == 1 + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return self._zone_type + + @callback + def _zones_updated(self, zones): + """Update the zone's state, if needed.""" + if self._zone_number in zones \ + and self._state != zones[self._zone_number]: + self._state = zones[self._zone_number] + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/skybell.py b/homeassistant/components/binary_sensor/skybell.py new file mode 100644 index 0000000000000..734f8e03375e5 --- /dev/null +++ b/homeassistant/components/binary_sensor/skybell.py @@ -0,0 +1,97 @@ +""" +Binary sensor support for the Skybell HD Doorbell. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.skybell/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.skybell import ( + DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice) +from homeassistant.const import ( + CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS) +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['skybell'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=5) + +# Sensor types: Name, device_class, event +SENSOR_TYPES = { + 'button': ['Button', 'occupancy', 'device:sensor:button'], + 'motion': ['Motion', 'motion', 'device:sensor:motion'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the platform for a Skybell device.""" + skybell = hass.data.get(SKYBELL_DOMAIN) + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + for device in skybell.get_devices(): + sensors.append(SkybellBinarySensor(device, sensor_type)) + + add_devices(sensors, True) + + +class SkybellBinarySensor(SkybellDevice, BinarySensorDevice): + """A binary sensor implementation for Skybell devices.""" + + def __init__(self, device, sensor_type): + """Initialize a binary sensor for a Skybell device.""" + super().__init__(device) + self._sensor_type = sensor_type + self._name = "{0} {1}".format(self._device.name, + SENSOR_TYPES[self._sensor_type][0]) + self._device_class = SENSOR_TYPES[self._sensor_type][1] + self._event = {} + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of the binary sensor.""" + return self._device_class + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = super().device_state_attributes + + attrs['event_date'] = self._event.get('createdAt') + + return attrs + + def update(self): + """Get the latest data and updates the state.""" + super().update() + + event = self._device.latest(SENSOR_TYPES[self._sensor_type][2]) + + self._state = bool(event and event.get('id') != self._event.get('id')) + + self._event = event diff --git a/homeassistant/components/binary_sensor/spc.py b/homeassistant/components/binary_sensor/spc.py index 8023e1cf4b336..95723f938704f 100644 --- a/homeassistant/components/binary_sensor/spc.py +++ b/homeassistant/components/binary_sensor/spc.py @@ -4,58 +4,61 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.spc/ """ -import logging import asyncio +import logging -from homeassistant.components.spc import ( - ATTR_DISCOVER_DEVICES, DATA_REGISTRY) from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.const import (STATE_UNAVAILABLE, STATE_ON, STATE_OFF) - +from homeassistant.components.spc import ATTR_DISCOVER_DEVICES, DATA_REGISTRY +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE _LOGGER = logging.getLogger(__name__) -SPC_TYPE_TO_DEVICE_CLASS = {'0': 'motion', - '1': 'opening', - '3': 'smoke'} - +SPC_TYPE_TO_DEVICE_CLASS = { + '0': 'motion', + '1': 'opening', + '3': 'smoke', +} -SPC_INPUT_TO_SENSOR_STATE = {'0': STATE_OFF, - '1': STATE_ON} +SPC_INPUT_TO_SENSOR_STATE = { + '0': STATE_OFF, + '1': STATE_ON, +} def _get_device_class(spc_type): + """Get the device class.""" return SPC_TYPE_TO_DEVICE_CLASS.get(spc_type, None) def _get_sensor_state(spc_input): + """Get the sensor state.""" return SPC_INPUT_TO_SENSOR_STATE.get(spc_input, STATE_UNAVAILABLE) def _create_sensor(hass, zone): - return SpcBinarySensor(zone_id=zone['id'], - name=zone['zone_name'], - state=_get_sensor_state(zone['input']), - device_class=_get_device_class(zone['type']), - spc_registry=hass.data[DATA_REGISTRY]) + """Create a SPC sensor.""" + return SpcBinarySensor( + zone_id=zone['id'], name=zone['zone_name'], + state=_get_sensor_state(zone['input']), + device_class=_get_device_class(zone['type']), + spc_registry=hass.data[DATA_REGISTRY]) @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Initialize the platform.""" +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the SPC binary sensor.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_DEVICES] is None): return - async_add_entities( + async_add_devices( _create_sensor(hass, zone) for zone in discovery_info[ATTR_DISCOVER_DEVICES] if _get_device_class(zone['type'])) class SpcBinarySensor(BinarySensorDevice): - """Represents a sensor based on an SPC zone.""" + """Representation of a sensor based on a SPC zone.""" def __init__(self, zone_id, name, state, device_class, spc_registry): """Initialize the sensor device.""" @@ -67,14 +70,14 @@ def __init__(self, zone_id, name, state, device_class, spc_registry): spc_registry.register_sensor_device(zone_id, self) @asyncio.coroutine - def async_update_from_spc(self, state): + def async_update_from_spc(self, state, extra): """Update the state of the device.""" self._state = state yield from self.async_update_ha_state() @property def name(self): - """The name of the device.""" + """Return the name of the device.""" return self._name @property @@ -85,7 +88,7 @@ def is_on(self): @property def hidden(self) -> bool: """Whether the device is hidden by default.""" - # these type of sensors are probably mainly used for automations + # These type of sensors are probably mainly used for automations return True @property @@ -95,5 +98,5 @@ def should_poll(self): @property def device_class(self): - """The device class.""" + """Return the device class.""" return self._device_class diff --git a/homeassistant/components/binary_sensor/tapsaff.py b/homeassistant/components/binary_sensor/tapsaff.py index 565abb73b36ec..c0f6ca3f11225 100644 --- a/homeassistant/components/binary_sensor/tapsaff.py +++ b/homeassistant/components/binary_sensor/tapsaff.py @@ -4,17 +4,17 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.tapsaff/ """ -import logging from datetime import timedelta +import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_NAME) + PLATFORM_SCHEMA, BinarySensorDevice) +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__) @@ -67,7 +67,7 @@ class TapsAffData(object): """Class for handling the data retrieval for pins.""" def __init__(self, location): - """Initialize the sensor.""" + """Initialize the data object.""" from tapsaff import TapsAff self._is_taps_aff = None diff --git a/homeassistant/components/binary_sensor/tellduslive.py b/homeassistant/components/binary_sensor/tellduslive.py new file mode 100644 index 0000000000000..e5d2d83fe47ce --- /dev/null +++ b/homeassistant/components/binary_sensor/tellduslive.py @@ -0,0 +1,34 @@ +""" +Support for binary sensors using Tellstick Net. + +This platform uses the Telldus Live online service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.tellduslive/ + +""" +import logging + +from homeassistant.components.tellduslive import TelldusLiveEntity +from homeassistant.components.binary_sensor import BinarySensorDevice + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tellstick sensors.""" + if discovery_info is None: + return + add_devices( + TelldusLiveSensor(hass, binary_sensor) + for binary_sensor in discovery_info + ) + + +class TelldusLiveSensor(TelldusLiveEntity, BinarySensorDevice): + """Representation of a Tellstick sensor.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self.device.is_on diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 413804f085667..68ffbf77af252 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -15,13 +15,13 @@ DEVICE_CLASSES_SCHEMA) from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, CONF_VALUE_TEMPLATE, - CONF_SENSORS, CONF_DEVICE_CLASS, EVENT_HOMEASSISTANT_START, STATE_ON) + CONF_ICON_TEMPLATE, CONF_ENTITY_PICTURE_TEMPLATE, + CONF_SENSORS, CONF_DEVICE_CLASS, EVENT_HOMEASSISTANT_START) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.event import ( async_track_state_change, async_track_same_state) -from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) @@ -30,6 +30,8 @@ SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, @@ -51,6 +53,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): for device, device_config in config[CONF_SENSORS].items(): value_template = device_config[CONF_VALUE_TEMPLATE] + icon_template = device_config.get(CONF_ICON_TEMPLATE) + entity_picture_template = device_config.get( + CONF_ENTITY_PICTURE_TEMPLATE) entity_ids = (device_config.get(ATTR_ENTITY_ID) or value_template.extract_entities()) friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) @@ -61,10 +66,17 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if value_template is not None: value_template.hass = hass + if icon_template is not None: + icon_template.hass = hass + + if entity_picture_template is not None: + entity_picture_template.hass = hass + sensors.append( BinarySensorTemplate( hass, device, friendly_name, device_class, value_template, - entity_ids, delay_on, delay_off) + icon_template, entity_picture_template, entity_ids, + delay_on, delay_off) ) if not sensors: _LOGGER.error("No sensors added") @@ -78,7 +90,8 @@ class BinarySensorTemplate(BinarySensorDevice): """A virtual binary sensor that triggers from another sensor.""" def __init__(self, hass, device, friendly_name, device_class, - value_template, entity_ids, delay_on, delay_off): + value_template, icon_template, entity_picture_template, + entity_ids, delay_on, delay_off): """Initialize the Template binary sensor.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -87,6 +100,10 @@ def __init__(self, hass, device, friendly_name, device_class, self._device_class = device_class self._template = value_template self._state = None + self._icon_template = icon_template + self._entity_picture_template = entity_picture_template + self._icon = None + self._entity_picture = None self._entities = entity_ids self._delay_on = delay_on self._delay_off = delay_off @@ -94,10 +111,6 @@ def __init__(self, hass, device, friendly_name, device_class, @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - state = yield from async_get_last_state(self.hass, self.entity_id) - if state: - self._state = state.state == STATE_ON - @callback def template_bsensor_state_listener(entity, old_state, new_state): """Handle the target device state changes.""" @@ -119,6 +132,16 @@ def name(self): """Return the name of the sensor.""" return self._name + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + + @property + def entity_picture(self): + """Return the entity_picture to use in the frontend, if any.""" + return self._entity_picture + @property def is_on(self): """Return true if sensor is on.""" @@ -135,10 +158,11 @@ def should_poll(self): return False @callback - def _async_render(self, *args): + def _async_render(self): """Get the state of template.""" + state = None try: - return self._template.async_render().lower() == 'true' + state = (self._template.async_render().lower() == 'true') except TemplateError as ex: if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): @@ -148,6 +172,29 @@ def _async_render(self, *args): return _LOGGER.error("Could not render template %s: %s", self._name, ex) + for property_name, template in ( + ('_icon', self._icon_template), + ('_entity_picture', self._entity_picture_template)): + if template is None: + continue + + try: + setattr(self, property_name, template.async_render()) + except TemplateError as ex: + friendly_property_name = property_name[1:].replace('_', ' ') + if ex.args and ex.args[0].startswith( + "UndefinedError: 'None' has no attribute"): + # Common during HA startup - so just a warning + _LOGGER.warning('Could not render %s template %s,' + ' the state is unknown.', + friendly_property_name, self._name) + else: + _LOGGER.error('Could not render %s template %s: %s', + friendly_property_name, self._name, ex) + return state + + return state + @callback def async_check_state(self): """Update the state from the template.""" @@ -161,7 +208,7 @@ def async_check_state(self): def set_state(): """Set state of template binary sensor.""" self._state = state - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # state without delay if (state and not self._delay_on) or \ @@ -171,5 +218,5 @@ def set_state(): period = self._delay_on if state else self._delay_off async_track_same_state( - self.hass, state, period, set_state, entity_ids=self._entities, - async_check_func=self._async_render) + self.hass, period, set_state, entity_ids=self._entities, + async_check_same_func=lambda *args: self._async_render() == state) diff --git a/homeassistant/components/binary_sensor/tesla.py b/homeassistant/components/binary_sensor/tesla.py index af7e394b50e11..3d494a28ea8ea 100644 --- a/homeassistant/components/binary_sensor/tesla.py +++ b/homeassistant/components/binary_sensor/tesla.py @@ -28,9 +28,8 @@ class TeslaBinarySensor(TeslaDevice, BinarySensorDevice): """Implement an Tesla binary sensor for parking and charger.""" def __init__(self, tesla_device, controller, sensor_type): - """Initialisation of binary sensor.""" + """Initialise of a Tesla binary sensor.""" super().__init__(tesla_device, controller) - self._name = self.tesla_device.name self._state = False self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) self._sensor_type = sensor_type diff --git a/homeassistant/components/binary_sensor/threshold.py b/homeassistant/components/binary_sensor/threshold.py index 866e16ecbe22d..79c36fb2ef231 100644 --- a/homeassistant/components/binary_sensor/threshold.py +++ b/homeassistant/components/binary_sensor/threshold.py @@ -9,35 +9,48 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) from homeassistant.const import ( - CONF_NAME, CONF_ENTITY_ID, CONF_TYPE, STATE_UNKNOWN, - ATTR_ENTITY_ID, CONF_DEVICE_CLASS) + ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, + STATE_UNKNOWN) from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change _LOGGER = logging.getLogger(__name__) +ATTR_HYSTERESIS = 'hysteresis' +ATTR_LOWER = 'lower' +ATTR_POSITION = 'position' ATTR_SENSOR_VALUE = 'sensor_value' -ATTR_THRESHOLD = 'threshold' ATTR_TYPE = 'type' +ATTR_UPPER = 'upper' +CONF_HYSTERESIS = 'hysteresis' CONF_LOWER = 'lower' -CONF_THRESHOLD = 'threshold' CONF_UPPER = 'upper' DEFAULT_NAME = 'Threshold' +DEFAULT_HYSTERESIS = 0.0 + +POSITION_ABOVE = 'above' +POSITION_BELOW = 'below' +POSITION_IN_RANGE = 'in_range' +POSITION_UNKNOWN = 'unknown' -SENSOR_TYPES = [CONF_LOWER, CONF_UPPER] +TYPE_LOWER = 'lower' +TYPE_RANGE = 'range' +TYPE_UPPER = 'upper' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Required(CONF_THRESHOLD): vol.Coerce(float), - vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): + vol.Coerce(float), + vol.Optional(CONF_LOWER): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UPPER): vol.Coerce(float), }) @@ -46,43 +59,44 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Threshold sensor.""" entity_id = config.get(CONF_ENTITY_ID) name = config.get(CONF_NAME) - threshold = config.get(CONF_THRESHOLD) - limit_type = config.get(CONF_TYPE) + lower = config.get(CONF_LOWER) + upper = config.get(CONF_UPPER) + hysteresis = config.get(CONF_HYSTERESIS) device_class = config.get(CONF_DEVICE_CLASS) - async_add_devices( - [ThresholdSensor(hass, entity_id, name, threshold, limit_type, - device_class)], True) - return True + async_add_devices([ThresholdSensor( + hass, entity_id, name, lower, upper, hysteresis, device_class)], True) class ThresholdSensor(BinarySensorDevice): """Representation of a Threshold sensor.""" - def __init__(self, hass, entity_id, name, threshold, limit_type, + def __init__(self, hass, entity_id, name, lower, upper, hysteresis, device_class): """Initialize the Threshold sensor.""" self._hass = hass self._entity_id = entity_id - self.is_upper = limit_type == 'upper' self._name = name - self._threshold = threshold + self._threshold_lower = lower + self._threshold_upper = upper + self._hysteresis = hysteresis self._device_class = device_class - self._deviation = False - self.sensor_value = 0 - @callback + self._state_position = None + self._state = False + self.sensor_value = None + # pylint: disable=invalid-name + @callback def async_threshold_sensor_state_listener( entity, old_state, new_state): """Handle sensor state changes.""" - if new_state.state == STATE_UNKNOWN: - return - try: - self.sensor_value = float(new_state.state) - except ValueError: - _LOGGER.error("State is not numerical") + self.sensor_value = None if new_state.state == STATE_UNKNOWN \ + else float(new_state.state) + except (ValueError, TypeError): + self.sensor_value = None + _LOGGER.warning("State is not numerical") hass.async_add_job(self.async_update_ha_state, True) @@ -97,7 +111,7 @@ def name(self): @property def is_on(self): """Return true if sensor is on.""" - return self._deviation + return self._state @property def should_poll(self): @@ -109,20 +123,68 @@ def device_class(self): """Return the sensor class of the sensor.""" return self._device_class + @property + def threshold_type(self): + """Return the type of threshold this sensor represents.""" + if self._threshold_lower is not None and \ + self._threshold_upper is not None: + return TYPE_RANGE + elif self._threshold_lower is not None: + return TYPE_LOWER + elif self._threshold_upper is not None: + return TYPE_UPPER + @property def device_state_attributes(self): """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, + ATTR_HYSTERESIS: self._hysteresis, + ATTR_LOWER: self._threshold_lower, + ATTR_POSITION: self._state_position, ATTR_SENSOR_VALUE: self.sensor_value, - ATTR_THRESHOLD: self._threshold, - ATTR_TYPE: CONF_UPPER if self.is_upper else CONF_LOWER, + ATTR_TYPE: self.threshold_type, + ATTR_UPPER: self._threshold_upper, } @asyncio.coroutine def async_update(self): """Get the latest data and updates the states.""" - if self.is_upper: - self._deviation = bool(self.sensor_value > self._threshold) - else: - self._deviation = bool(self.sensor_value < self._threshold) + def below(threshold): + """Determine if the sensor value is below a threshold.""" + return self.sensor_value < (threshold - self._hysteresis) + + def above(threshold): + """Determine if the sensor value is above a threshold.""" + return self.sensor_value > (threshold + self._hysteresis) + + if self.sensor_value is None: + self._state_position = POSITION_UNKNOWN + self._state = False + + elif self.threshold_type == TYPE_LOWER: + if below(self._threshold_lower): + self._state_position = POSITION_BELOW + self._state = True + elif above(self._threshold_lower): + self._state_position = POSITION_ABOVE + self._state = False + + elif self.threshold_type == TYPE_UPPER: + if above(self._threshold_upper): + self._state_position = POSITION_ABOVE + self._state = True + elif below(self._threshold_upper): + self._state_position = POSITION_BELOW + self._state = False + + elif self.threshold_type == TYPE_RANGE: + if below(self._threshold_lower): + self._state_position = POSITION_BELOW + self._state = False + if above(self._threshold_upper): + self._state_position = POSITION_ABOVE + self._state = False + elif above(self._threshold_lower) and below(self._threshold_upper): + self._state_position = POSITION_IN_RANGE + self._state = True diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index 88fdb448330d8..5405a6a77ba57 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -1,36 +1,55 @@ """ -A sensor that monitors trands in other components. +A sensor that monitors trends in other components. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.trend/ """ import asyncio +from collections import deque import logging +import math import voluptuous as vol -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv - from homeassistant.components.binary_sensor import ( - BinarySensorDevice, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - DEVICE_CLASSES_SCHEMA) + DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, + BinarySensorDevice) from homeassistant.const import ( - ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, CONF_DEVICE_CLASS, STATE_UNKNOWN) + ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_DEVICE_CLASS, CONF_ENTITY_ID, + CONF_FRIENDLY_NAME, STATE_UNKNOWN) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.event import async_track_state_change +from homeassistant.util import utcnow + +REQUIREMENTS = ['numpy==1.14.3'] _LOGGER = logging.getLogger(__name__) -CONF_SENSORS = 'sensors' + +ATTR_ATTRIBUTE = 'attribute' +ATTR_GRADIENT = 'gradient' +ATTR_MIN_GRADIENT = 'min_gradient' +ATTR_INVERT = 'invert' +ATTR_SAMPLE_DURATION = 'sample_duration' +ATTR_SAMPLE_COUNT = 'sample_count' + CONF_ATTRIBUTE = 'attribute' CONF_INVERT = 'invert' +CONF_MAX_SAMPLES = 'max_samples' +CONF_MIN_GRADIENT = 'min_gradient' +CONF_SAMPLE_DURATION = 'sample_duration' +CONF_SENSORS = 'sensors' SENSOR_SCHEMA = vol.Schema({ - vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(ATTR_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_INVERT, default=False): cv.boolean, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, + vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, + vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), + vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -43,17 +62,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the trend sensors.""" sensors = [] - for device, device_config in config[CONF_SENSORS].items(): + for device_id, device_config in config[CONF_SENSORS].items(): entity_id = device_config[ATTR_ENTITY_ID] attribute = device_config.get(CONF_ATTRIBUTE) - friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) device_class = device_config.get(CONF_DEVICE_CLASS) + friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device_id) invert = device_config[CONF_INVERT] + max_samples = device_config[CONF_MAX_SAMPLES] + min_gradient = device_config[CONF_MIN_GRADIENT] + sample_duration = device_config[CONF_SAMPLE_DURATION] sensors.append( SensorTrend( - hass, device, friendly_name, entity_id, attribute, - device_class, invert) + hass, device_id, friendly_name, entity_id, attribute, + device_class, invert, max_samples, min_gradient, + sample_duration) ) if not sensors: _LOGGER.error("No sensors added") @@ -65,30 +88,23 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class SensorTrend(BinarySensorDevice): """Representation of a trend Sensor.""" - def __init__(self, hass, device_id, friendly_name, - target_entity, attribute, device_class, invert): + def __init__(self, hass, device_id, friendly_name, entity_id, + attribute, device_class, invert, max_samples, + min_gradient, sample_duration): """Initialize the sensor.""" self._hass = hass self.entity_id = generate_entity_id( ENTITY_ID_FORMAT, device_id, hass=hass) self._name = friendly_name - self._target_entity = target_entity + self._entity_id = entity_id self._attribute = attribute self._device_class = device_class self._invert = invert + self._sample_duration = sample_duration + self._min_gradient = min_gradient + self._gradient = None self._state = None - self.from_state = None - self.to_state = None - - @callback - def trend_sensor_state_listener(entity, old_state, new_state): - """Handle the target device state changes.""" - self.from_state = old_state - self.to_state = new_state - hass.async_add_job(self.async_update_ha_state(True)) - - track_state_change(hass, target_entity, - trend_sensor_state_listener) + self.samples = deque(maxlen=max_samples) @property def name(self): @@ -105,33 +121,77 @@ def device_class(self): """Return the sensor class of the sensor.""" return self._device_class + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_ENTITY_ID: self._entity_id, + ATTR_FRIENDLY_NAME: self._name, + ATTR_GRADIENT: self._gradient, + ATTR_INVERT: self._invert, + ATTR_MIN_GRADIENT: self._min_gradient, + ATTR_SAMPLE_COUNT: len(self.samples), + ATTR_SAMPLE_DURATION: self._sample_duration, + } + @property def should_poll(self): """No polling needed.""" return False + @asyncio.coroutine + def async_added_to_hass(self): + """Complete device setup after being added to hass.""" + @callback + def trend_sensor_state_listener(entity, old_state, new_state): + """Handle state changes on the observed device.""" + try: + if self._attribute: + state = new_state.attributes.get(self._attribute) + else: + state = new_state.state + if state != STATE_UNKNOWN: + sample = (utcnow().timestamp(), float(state)) + self.samples.append(sample) + self.async_schedule_update_ha_state(True) + except (ValueError, TypeError) as ex: + _LOGGER.error(ex) + + async_track_state_change( + self.hass, self._entity_id, + trend_sensor_state_listener) + @asyncio.coroutine def async_update(self): """Get the latest data and update the states.""" - if self.from_state is None or self.to_state is None: - return - if (self.from_state.state == STATE_UNKNOWN or - self.to_state.state == STATE_UNKNOWN): + # Remove outdated samples + if self._sample_duration > 0: + cutoff = utcnow().timestamp() - self._sample_duration + while self.samples and self.samples[0][0] < cutoff: + self.samples.popleft() + + if len(self.samples) < 2: return - try: - if self._attribute: - from_value = float( - self.from_state.attributes.get(self._attribute)) - to_value = float( - self.to_state.attributes.get(self._attribute)) - else: - from_value = float(self.from_state.state) - to_value = float(self.to_state.state) - - self._state = to_value > from_value - if self._invert: - self._state = not self._state - - except (ValueError, TypeError) as ex: - self._state = None - _LOGGER.error(ex) + + # Calculate gradient of linear trend + yield from self.hass.async_add_job(self._calculate_gradient) + + # Update state + self._state = ( + abs(self._gradient) > abs(self._min_gradient) and + math.copysign(self._gradient, self._min_gradient) == self._gradient + ) + + if self._invert: + self._state = not self._state + + def _calculate_gradient(self): + """Compute the linear trend gradient of the current samples. + + This need run inside executor. + """ + import numpy as np + timestamps = np.array([t for t, _ in self.samples]) + values = np.array([s for _, s in self.samples]) + coeffs = np.polyfit(timestamps, values, 1) + self._gradient = coeffs[0] diff --git a/homeassistant/components/binary_sensor/upcloud.py b/homeassistant/components/binary_sensor/upcloud.py new file mode 100644 index 0000000000000..868a2e8ddd062 --- /dev/null +++ b/homeassistant/components/binary_sensor/upcloud.py @@ -0,0 +1,38 @@ +""" +Support for monitoring the state of UpCloud servers. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.upcloud/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.upcloud import ( + UpCloudServerEntity, CONF_SERVERS, DATA_UPCLOUD) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['upcloud'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SERVERS): vol.All(cv.ensure_list, [cv.string]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the UpCloud server binary sensor.""" + upcloud = hass.data[DATA_UPCLOUD] + + servers = config.get(CONF_SERVERS) + + devices = [UpCloudBinarySensor(upcloud, uuid) for uuid in servers] + + add_devices(devices, True) + + +class UpCloudBinarySensor(UpCloudServerEntity, BinarySensorDevice): + """Representation of an UpCloud server sensor.""" diff --git a/homeassistant/components/binary_sensor/vera.py b/homeassistant/components/binary_sensor/vera.py index e16f4e17fa058..e87886376bc30 100644 --- a/homeassistant/components/binary_sensor/vera.py +++ b/homeassistant/components/binary_sensor/vera.py @@ -19,8 +19,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Perform the setup for Vera controller devices.""" add_devices( - VeraBinarySensor(device, VERA_CONTROLLER) - for device in VERA_DEVICES['binary_sensor']) + VeraBinarySensor(device, hass.data[VERA_CONTROLLER]) + for device in hass.data[VERA_DEVICES]['binary_sensor']) class VeraBinarySensor(VeraDevice, BinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/verisure.py b/homeassistant/components/binary_sensor/verisure.py index 8702c8bd7701d..4a1b99f4b9bc9 100644 --- a/homeassistant/components/binary_sensor/verisure.py +++ b/homeassistant/components/binary_sensor/verisure.py @@ -6,15 +6,15 @@ """ import logging -from homeassistant.components.verisure import HUB as hub from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.verisure import CONF_DOOR_WINDOW +from homeassistant.components.verisure import HUB as hub _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup Verisure binary sensors.""" + """Set up the Verisure binary sensors.""" sensors = [] hub.update_overview() @@ -27,10 +27,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class VerisureDoorWindowSensor(BinarySensorDevice): - """Verisure door window sensor.""" + """Representation of a Verisure door window sensor.""" def __init__(self, device_label): - """Initialize the modbus coil sensor.""" + """Initialize the Verisure door window sensor.""" self._device_label = device_label @property diff --git a/homeassistant/components/binary_sensor/vultr.py b/homeassistant/components/binary_sensor/vultr.py new file mode 100644 index 0000000000000..eecd3f87c4043 --- /dev/null +++ b/homeassistant/components/binary_sensor/vultr.py @@ -0,0 +1,103 @@ +""" +Support for monitoring the state of Vultr subscriptions (VPS). + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.vultr/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.vultr import ( + CONF_SUBSCRIPTION, ATTR_AUTO_BACKUPS, ATTR_ALLOWED_BANDWIDTH, + ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID, ATTR_SUBSCRIPTION_NAME, + ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, ATTR_DISK, + ATTR_COST_PER_MONTH, ATTR_OS, ATTR_REGION, ATTR_VCPUS, DATA_VULTR) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DEVICE_CLASS = 'power' +DEFAULT_NAME = 'Vultr {}' +DEPENDENCIES = ['vultr'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SUBSCRIPTION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Vultr subscription (server) binary sensor.""" + vultr = hass.data[DATA_VULTR] + + subscription = config.get(CONF_SUBSCRIPTION) + name = config.get(CONF_NAME) + + if subscription not in vultr.data: + _LOGGER.error("Subscription %s not found", subscription) + return + + add_devices([VultrBinarySensor(vultr, subscription, name)], True) + + +class VultrBinarySensor(BinarySensorDevice): + """Representation of a Vultr subscription sensor.""" + + def __init__(self, vultr, subscription, name): + """Initialize a new Vultr binary sensor.""" + self._vultr = vultr + self._name = name + + self.subscription = subscription + self.data = None + + @property + def name(self): + """Return the name of the sensor.""" + try: + return self._name.format(self.data['label']) + except (KeyError, TypeError): + return self._name + + @property + def icon(self): + """Return the icon of this server.""" + return 'mdi:server' if self.is_on else 'mdi:server-off' + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.data['power_status'] == 'running' + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEFAULT_DEVICE_CLASS + + @property + def device_state_attributes(self): + """Return the state attributes of the Vultr subscription.""" + return { + ATTR_ALLOWED_BANDWIDTH: self.data.get('allowed_bandwidth_gb'), + ATTR_AUTO_BACKUPS: self.data.get('auto_backups'), + ATTR_COST_PER_MONTH: self.data.get('cost_per_month'), + ATTR_CREATED_AT: self.data.get('date_created'), + ATTR_DISK: self.data.get('disk'), + ATTR_IPV4_ADDRESS: self.data.get('main_ip'), + ATTR_IPV6_ADDRESS: self.data.get('v6_main_ip'), + ATTR_MEMORY: self.data.get('ram'), + ATTR_OS: self.data.get('os'), + ATTR_REGION: self.data.get('location'), + ATTR_SUBSCRIPTION_ID: self.data.get('SUBID'), + ATTR_SUBSCRIPTION_NAME: self.data.get('label'), + ATTR_VCPUS: self.data.get('vcpu_count') + } + + def update(self): + """Update state of sensor.""" + self._vultr.update() + self.data = self._vultr.data[self.subscription] diff --git a/homeassistant/components/binary_sensor/wemo.py b/homeassistant/components/binary_sensor/wemo.py index 1ec9e703eab46..30a7e291401bc 100644 --- a/homeassistant/components/binary_sensor/wemo.py +++ b/homeassistant/components/binary_sensor/wemo.py @@ -7,7 +7,6 @@ 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) @@ -58,11 +57,11 @@ def should_poll(self): @property def unique_id(self): """Return the id of this WeMo device.""" - return '{}.{}'.format(self.__class__, self.wemo.serialnumber) + return self.wemo.serialnumber @property def name(self): - """Return the name of the sevice if any.""" + """Return the name of the service if any.""" return self.wemo.name @property diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py index b4910687da7d3..575507cd04780 100644 --- a/homeassistant/components/binary_sensor/wink.py +++ b/homeassistant/components/binary_sensor/wink.py @@ -8,8 +8,7 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.wink import WinkDevice, DOMAIN -from homeassistant.helpers.entity import Entity +from homeassistant.components.wink import DOMAIN, WinkDevice _LOGGER = logging.getLogger(__name__) @@ -17,18 +16,18 @@ # These are the available sensors mapped to binary_sensor class SENSOR_TYPES = { - 'opened': 'opening', 'brightness': 'light', - 'vibration': 'vibration', - 'loudness': 'sound', - 'noise': 'sound', 'capturing_audio': 'sound', + 'capturing_video': None, + 'co_detected': 'gas', 'liquid_detected': 'moisture', + 'loudness': 'sound', 'motion': 'motion', + 'noise': 'sound', + 'opened': 'opening', 'presence': 'occupancy', - 'co_detected': 'gas', 'smoke_detected': 'smoke', - 'capturing_video': None + 'vibration': 'vibration', } @@ -87,7 +86,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.info("Device isn't a sensor, skipping") -class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity): +class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice): """Representation of a Wink binary sensor.""" def __init__(self, wink, hass): @@ -104,7 +103,7 @@ def __init__(self, wink, hass): @asyncio.coroutine def async_added_to_hass(self): - """Callback when entity is added to hass.""" + """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['binary_sensor'].append(self) @property @@ -117,16 +116,21 @@ def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" return SENSOR_TYPES.get(self.capability) + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return super().device_state_attributes + class WinkSmokeDetector(WinkBinarySensorDevice): """Representation of a Wink Smoke detector.""" @property def device_state_attributes(self): - """Return the state attributes.""" - return { - 'test_activated': self.wink.test_activated() - } + """Return the device state attributes.""" + _attributes = super().device_state_attributes + _attributes['test_activated'] = self.wink.test_activated() + return _attributes class WinkHub(WinkBinarySensorDevice): @@ -134,11 +138,19 @@ class WinkHub(WinkBinarySensorDevice): @property def device_state_attributes(self): - """Return the state attributes.""" - return { - 'update needed': self.wink.update_needed(), - 'firmware version': self.wink.firmware_version() - } + """Return the device state attributes.""" + _attributes = super().device_state_attributes + _attributes['update_needed'] = self.wink.update_needed() + _attributes['firmware_version'] = self.wink.firmware_version() + _attributes['pairing_mode'] = self.wink.pairing_mode() + _kidde_code = self.wink.kidde_radio_code() + if _kidde_code is not None: + # The service call to set the Kidde code + # takes a string of 1s and 0s so it makes + # sense to display it to the user that way + _formatted_kidde_code = "{:b}".format(_kidde_code).zfill(8) + _attributes['kidde_radio_code'] = _formatted_kidde_code + return _attributes class WinkRemote(WinkBinarySensorDevice): @@ -147,12 +159,12 @@ class WinkRemote(WinkBinarySensorDevice): @property def device_state_attributes(self): """Return the state attributes.""" - return { - 'button_on_pressed': self.wink.button_on_pressed(), - 'button_off_pressed': self.wink.button_off_pressed(), - 'button_up_pressed': self.wink.button_up_pressed(), - 'button_down_pressed': self.wink.button_down_pressed() - } + _attributes = super().device_state_attributes + _attributes['button_on_pressed'] = self.wink.button_on_pressed() + _attributes['button_off_pressed'] = self.wink.button_off_pressed() + _attributes['button_up_pressed'] = self.wink.button_up_pressed() + _attributes['button_down_pressed'] = self.wink.button_down_pressed() + return _attributes @property def device_class(self): @@ -165,11 +177,11 @@ class WinkButton(WinkBinarySensorDevice): @property def device_state_attributes(self): - """Return the state attributes.""" - return { - 'pressed': self.wink.pressed(), - 'long_pressed': self.wink.long_pressed() - } + """Return the device state attributes.""" + _attributes = super().device_state_attributes + _attributes['pressed'] = self.wink.pressed() + _attributes['long_pressed'] = self.wink.long_pressed() + return _attributes class WinkGang(WinkBinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index f48525d41a8a1..b37be3f6cb693 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -17,17 +17,22 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['holidays==0.8.1'] +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', 'Canada', 'CA', - 'Colombia', 'CO', 'Czech', 'CZ', 'Denmark', 'DK', 'England', - 'EuropeanCentralBank', 'ECB', 'TAR', 'Germany', 'DE', - 'Ireland', 'Isle of Man', 'Mexico', 'MX', 'Netherlands', 'NL', - 'NewZealand', 'NZ', 'Northern Ireland', 'Norway', 'NO', - 'Portugal', 'PT', 'PortugalExt', 'PTE', 'Scotland', 'Spain', - 'ES', 'UnitedKingdom', 'UK', 'UnitedStates', 'US', 'Wales'] +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', 'Switzerland', 'CH', 'UnitedKingdom', 'UK', + 'UnitedStates', 'US', 'Wales'] CONF_COUNTRY = 'country' CONF_PROVINCE = 'province' CONF_WORKDAYS = 'workdays' @@ -43,13 +48,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_COUNTRY): vol.In(ALL_COUNTRIES), - vol.Optional(CONF_PROVINCE, default=None): 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)]), }) @@ -64,20 +69,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): excludes = config.get(CONF_EXCLUDES) days_offset = config.get(CONF_OFFSET) - year = (datetime.now() + timedelta(days=days_offset)).year + year = (get_date(datetime.today()) + timedelta(days=days_offset)).year obj_holidays = getattr(holidays, country)(years=year) 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) @@ -99,6 +104,11 @@ def day_to_string(day): return None +def get_date(date): + """Return date. Needed for testing.""" + return date + + class IsWorkdaySensor(BinarySensorDevice): """Implementation of a Workday sensor.""" @@ -156,7 +166,7 @@ def async_update(self): self._state = False # Get iso day of the week (1 = Monday, 7 = Sunday) - date = datetime.today() + timedelta(days=self._days_offset) + date = get_date(datetime.today()) + timedelta(days=self._days_offset) day = date.isoweekday() - 1 day_of_week = day_to_string(day) diff --git a/homeassistant/components/binary_sensor/xiaomi.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py similarity index 79% rename from homeassistant/components/binary_sensor/xiaomi.py rename to homeassistant/components/binary_sensor/xiaomi_aqara.py index c5f0a7b3dce24..1c0b903d86879 100644 --- a/homeassistant/components/binary_sensor/xiaomi.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -1,8 +1,9 @@ -"""Support for Xiaomi binary sensors.""" +"""Support for Xiaomi aqara binary sensors.""" import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) _LOGGER = logging.getLogger(__name__) @@ -11,6 +12,7 @@ MOTION = 'motion' NO_MOTION = 'no_motion' +ATTR_LAST_ACTION = 'last_action' ATTR_NO_MOTION_SINCE = 'No motion since' DENSITY = 'density' @@ -23,37 +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 == 'motion': + if model in ['motion', 'sensor_motion', 'sensor_motion.aq2']: devices.append(XiaomiMotionSensor(device, hass, gateway)) - elif model == 'sensor_motion.aq2': - devices.append(XiaomiMotionSensor(device, hass, gateway)) - elif model == 'magnet': - devices.append(XiaomiDoorSensor(device, gateway)) - elif model == 'sensor_magnet.aq2': + elif model in ['magnet', 'sensor_magnet', '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 == 'switch': - devices.append(XiaomiButton(device, 'Switch', 'status', - hass, gateway)) - elif model == 'sensor_switch.aq2': - 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.aq1']: devices.append(XiaomiButton(device, 'Wall Switch', 'channel_0', hass, gateway)) - elif model == '86sw2': + elif model in ['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) @@ -106,7 +106,7 @@ def device_state_attributes(self): attrs.update(super().device_state_attributes) return attrs - def parse_data(self, data): + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" if DENSITY in data: self._density = int(data.get(DENSITY)) @@ -134,8 +134,12 @@ def __init__(self, device, hass, xiaomi_hub): """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): @@ -144,8 +148,16 @@ def device_state_attributes(self): attrs.update(super().device_state_attributes) return attrs - def parse_data(self, data): + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" + if raw_data['cmd'] == 'heartbeat': + _LOGGER.debug( + 'Skipping heartbeat of the motion sensor. ' + 'It can introduce an incorrect state because of a firmware ' + 'bug (https://github.com/home-assistant/home-assistant/pull/' + '11631#issuecomment-357507744).') + return + self._should_poll = False if NO_MOTION in data: # handle push from the hub self._no_motion_since = data[NO_MOTION] @@ -191,7 +203,7 @@ def device_state_attributes(self): attrs.update(super().device_state_attributes) return attrs - def parse_data(self, data): + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" self._should_poll = False if NO_CLOSE in data: # handle push from the hub @@ -224,7 +236,7 @@ def __init__(self, device, xiaomi_hub): XiaomiBinarySensor.__init__(self, device, 'Water Leak Sensor', xiaomi_hub, 'status', 'moisture') - def parse_data(self, data): + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" self._should_poll = False @@ -261,7 +273,7 @@ def device_state_attributes(self): attrs.update(super().device_state_attributes) return attrs - def parse_data(self, data): + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" if DENSITY in data: self._density = int(data.get(DENSITY)) @@ -287,10 +299,18 @@ class XiaomiButton(XiaomiBinarySensor): def __init__(self, device, name, data_key, hass, xiaomi_hub): """Initialize the XiaomiButton.""" self._hass = hass + self._last_action = None XiaomiBinarySensor.__init__(self, device, name, xiaomi_hub, data_key, None) - def parse_data(self, data): + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_LAST_ACTION: self._last_action} + attrs.update(super().device_state_attributes) + return attrs + + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" value = data.get(self._data_key) if value is None: @@ -308,13 +328,18 @@ def parse_data(self, data): click_type = 'double' elif value == 'both_click': click_type = 'both' + elif value == 'shake': + click_type = 'shake' else: + _LOGGER.warning("Unsupported click_type detected: %s", value) return False self._hass.bus.fire('click', { 'entity_id': self.entity_id, 'click_type': click_type }) + self._last_action = click_type + if value in ['long_click_press', 'long_click_release']: return True return False @@ -326,17 +351,26 @@ class XiaomiCube(XiaomiBinarySensor): def __init__(self, device, hass, xiaomi_hub): """Initialize the Xiaomi Cube.""" self._hass = hass + self._last_action = None self._state = False XiaomiBinarySensor.__init__(self, device, 'Cube', xiaomi_hub, None, None) - def parse_data(self, data): + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_LAST_ACTION: self._last_action} + attrs.update(super().device_state_attributes) + return attrs + + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" if 'status' in data: self._hass.bus.fire('cube_action', { 'entity_id': self.entity_id, 'action_type': data['status'] }) + self._last_action = data['status'] if 'rotate' in data: self._hass.bus.fire('cube_action', { @@ -344,4 +378,6 @@ def parse_data(self, data): 'action_type': 'rotate', 'action_value': float(data['rotate'].replace(",", ".")) }) - return False + self._last_action = 'rotate' + + return True diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index ad7c29badf97d..d3b31188760b0 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -4,7 +4,6 @@ For more details on this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.zha/ """ -import asyncio import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice @@ -25,37 +24,72 @@ } -@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 Zigbee Home Automation binary sensors.""" discovery_info = zha.get_discovery_info(hass, discovery_info) if discovery_info is None: return - from bellows.zigbee.zcl.clusters.security import IasZone + 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']: - yield from cluster.bind() + await cluster.bind() ieee = cluster.endpoint.device.application.ieee - yield from cluster.write_attributes({'cie_addr': ieee}) + await cluster.write_attributes({'cie_addr': ieee}) try: - zone_type = yield from cluster['zone_type'] + zone_type = await cluster['zone_type'] device_class = CLASS_MAPPING.get(zone_type, None) except Exception: # pylint: disable=broad-except # If we fail to read from the device, use a non-specific class pass sensor = BinarySensor(device_class, **discovery_info) - async_add_devices([sensor]) + 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 @@ -63,13 +97,18 @@ def __init__(self, device_class, **kwargs): """Initialize the ZHA binary sensor.""" super().__init__(**kwargs) self._device_class = device_class - from bellows.zigbee.zcl.clusters.security import IasZone + from zigpy.zcl.clusters.security import IasZone self._ias_zone_cluster = self._in_clusters[IasZone.cluster_id] + @property + def should_poll(self) -> bool: + """Let zha handle polling.""" + return False + @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) @@ -78,12 +117,137 @@ def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" return self._device_class - def cluster_command(self, aps_frame, tsn, command_id, args): + def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" if command_id == 0: self._state = args[0] & 3 _LOGGER.debug("Updated alarm state: %s", self._state) - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() elif command_id == 1: _LOGGER.debug("Enroll requested") - self.hass.add_job(self._ias_zone_cluster.enroll_response(0, 0)) + res = self._ias_zone_cluster.enroll_response(0, 0) + self.hass.async_add_job(res) + + async def async_update(self): + """Retrieve latest state.""" + from bellows.types.basic import uint16_t + + result = await zha.safe_read(self._endpoint.ias_zone, + ['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 = True + self._level = 255 + 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 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/bloomsky.py b/homeassistant/components/bloomsky.py index aff1c14b2525b..f04e0af7be9ab 100644 --- a/homeassistant/components/bloomsky.py +++ b/homeassistant/components/bloomsky.py @@ -4,16 +4,17 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/bloomsky/ """ -import logging from datetime import timedelta +import logging +from aiohttp.hdrs import AUTHORIZATION import requests import voluptuous as vol from homeassistant.const import CONF_API_KEY from homeassistant.helpers import discovery -from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) @@ -68,7 +69,7 @@ def refresh_devices(self): """Use the API to retrieve a list of devices.""" _LOGGER.debug("Fetching BloomSky update") response = requests.get( - self.API_URL, headers={"Authorization": self._api_key}, timeout=10) + self.API_URL, headers={AUTHORIZATION: self._api_key}, timeout=10) if response.status_code == 401: raise RuntimeError("Invalid API_KEY") elif response.status_code != 200: diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py new file mode 100644 index 0000000000000..a7ed262ac2c0c --- /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 0000000000000..b9605429a8efa --- /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/__init__.py b/homeassistant/components/calendar/__init__.py index 4e088c8a640b0..5198381b9767a 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -12,6 +12,7 @@ from homeassistant.components.google import ( CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME) from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.config_validation import time_period_str from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/calendar/caldav.py b/homeassistant/components/calendar/caldav.py new file mode 100644 index 0000000000000..6f92891c551d7 --- /dev/null +++ b/homeassistant/components/calendar/caldav.py @@ -0,0 +1,235 @@ +""" +Support for WebDav Calendar. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.caldav/ +""" +from datetime import datetime, timedelta +import logging +import re + +import voluptuous as vol + +from homeassistant.components.calendar import ( + PLATFORM_SCHEMA, CalendarEventDevice) +from homeassistant.const import ( + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle, dt + +REQUIREMENTS = ['caldav==0.5.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DEVICE_ID = 'device_id' +CONF_CALENDARS = 'calendars' +CONF_CUSTOM_CALENDARS = 'custom_calendars' +CONF_CALENDAR = 'calendar' +CONF_SEARCH = 'search' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + # pylint: disable=no-value-for-parameter + vol.Required(CONF_URL): vol.Url(), + vol.Optional(CONF_CALENDARS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + cv.string + ])), + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + vol.Optional(CONF_CUSTOM_CALENDARS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_CALENDAR): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SEARCH): cv.string, + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, disc_info=None): + """Set up the WebDav Calendar platform.""" + import caldav + + url = config.get(CONF_URL) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + client = caldav.DAVClient(url, None, username, password) + + calendars = client.principal().calendars() + + calendar_devices = [] + for calendar in list(calendars): + # If a calendar name was given in the configuration, + # ignore all the others + if (config.get(CONF_CALENDARS) + and calendar.name not in config.get(CONF_CALENDARS)): + _LOGGER.debug("Ignoring calendar '%s'", calendar.name) + continue + + # Create additional calendars based on custom filtering rules + for cust_calendar in config.get(CONF_CUSTOM_CALENDARS): + # Check that the base calendar matches + if cust_calendar.get(CONF_CALENDAR) != calendar.name: + continue + + device_data = { + CONF_NAME: cust_calendar.get(CONF_NAME), + CONF_DEVICE_ID: "{} {}".format( + cust_calendar.get(CONF_CALENDAR), + cust_calendar.get(CONF_NAME)), + } + + calendar_devices.append( + WebDavCalendarEventDevice( + hass, device_data, calendar, True, + cust_calendar.get(CONF_SEARCH))) + + # Create a default calendar if there was no custom one + if not config.get(CONF_CUSTOM_CALENDARS): + device_data = { + CONF_NAME: calendar.name, + CONF_DEVICE_ID: calendar.name + } + calendar_devices.append( + WebDavCalendarEventDevice(hass, device_data, calendar) + ) + + add_devices(calendar_devices) + + +class WebDavCalendarEventDevice(CalendarEventDevice): + """A device for getting the next Task from a WebDav Calendar.""" + + def __init__(self, hass, device_data, calendar, all_day=False, + search=None): + """Create the WebDav Calendar Event Device.""" + self.data = WebDavCalendarData(calendar, all_day, search) + super().__init__(hass, device_data) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + return attributes + + +class WebDavCalendarData(object): + """Class to utilize the calendar dav client object to get next event.""" + + def __init__(self, calendar, include_all_day, search): + """Set up how we are going to search the WebDav calendar.""" + self.calendar = calendar + self.include_all_day = include_all_day + self.search = search + self.event = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + # We have to retrieve the results for the whole day as the server + # won't return events that have already started + results = self.calendar.date_search( + dt.start_of_local_day(), + dt.start_of_local_day() + timedelta(days=1) + ) + + # dtstart can be a date or datetime depending if the event lasts a + # whole day. Convert everything to datetime to be able to sort it + results.sort(key=lambda x: self.to_datetime( + x.instance.vevent.dtstart.value + )) + + vevent = next(( + event.instance.vevent for event in results + if (self.is_matching(event.instance.vevent, self.search) + and (not self.is_all_day(event.instance.vevent) + or self.include_all_day) + and not self.is_over(event.instance.vevent))), None) + + # If no matching event could be found + if vevent is None: + _LOGGER.debug( + "No matching event found in the %d results for %s", + len(results), self.calendar.name) + self.event = None + return True + + # Populate the entity attributes with the event values + self.event = { + "summary": vevent.summary.value, + "start": self.get_hass_date(vevent.dtstart.value), + "end": self.get_hass_date(self.get_end_date(vevent)), + "location": self.get_attr_value(vevent, "location"), + "description": self.get_attr_value(vevent, "description") + } + return True + + @staticmethod + def is_matching(vevent, search): + """Return if the event matches the filter criteria.""" + if search is None: + return True + + pattern = re.compile(search) + return (hasattr(vevent, "summary") + and pattern.match(vevent.summary.value) + or hasattr(vevent, "location") + and pattern.match(vevent.location.value) + or hasattr(vevent, "description") + and pattern.match(vevent.description.value)) + + @staticmethod + def is_all_day(vevent): + """Return if the event last the whole day.""" + return not isinstance(vevent.dtstart.value, datetime) + + @staticmethod + def is_over(vevent): + """Return if the event is over.""" + return dt.now() >= WebDavCalendarData.to_datetime( + WebDavCalendarData.get_end_date(vevent) + ) + + @staticmethod + def get_hass_date(obj): + """Return if the event matches.""" + if isinstance(obj, datetime): + return {"dateTime": obj.isoformat()} + + return {"date": obj.isoformat()} + + @staticmethod + def to_datetime(obj): + """Return a datetime.""" + if isinstance(obj, datetime): + return obj + return dt.as_local(dt.dt.datetime.combine(obj, dt.dt.time.min)) + + @staticmethod + def get_attr_value(obj, attribute): + """Return the value of the attribute if defined.""" + if hasattr(obj, attribute): + return getattr(obj, attribute).value + return None + + @staticmethod + def get_end_date(obj): + """Return the end datetime as determined by dtend or duration.""" + if hasattr(obj, "dtend"): + enddate = obj.dtend.value + + elif hasattr(obj, "duration"): + enddate = obj.dtstart.value + obj.duration.value + + else: + enddate = obj.dtstart.value + timedelta(days=1) + + return enddate diff --git a/homeassistant/components/calendar/demo.py b/homeassistant/components/calendar/demo.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py index 098c7c70834be..6c26c65ebe77f 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -11,6 +11,7 @@ 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 @@ 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 @@ def update(self): 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 new file mode 100644 index 0000000000000..ebf0c7b1591ab --- /dev/null +++ b/homeassistant/components/calendar/services.yaml @@ -0,0 +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. + 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 new file mode 100644 index 0000000000000..b70e44456db82 --- /dev/null +++ b/homeassistant/components/calendar/todoist.py @@ -0,0 +1,550 @@ +""" +Support for Todoist task management (https://todoist.com). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.todoist/ +""" +from datetime import datetime, timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.calendar import ( + DOMAIN, PLATFORM_SCHEMA, CalendarEventDevice) +from homeassistant.components.google import CONF_DEVICE_ID +from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util import Throttle, dt + +REQUIREMENTS = ['todoist-python==7.0.17'] + +_LOGGER = logging.getLogger(__name__) + +CONF_EXTRA_PROJECTS = 'custom_projects' +CONF_PROJECT_DUE_DATE = 'due_date_days' +CONF_PROJECT_LABEL_WHITELIST = 'labels' +CONF_PROJECT_WHITELIST = 'include_projects' + +# Calendar Platform: Does this calendar event last all day? +ALL_DAY = 'all_day' +# Attribute: All tasks in this project +ALL_TASKS = 'all_tasks' +# Todoist API: "Completed" flag -- 1 if complete, else 0 +CHECKED = 'checked' +# Attribute: Is this task complete? +COMPLETED = 'completed' +# Todoist API: What is this task about? +# Service Call: What is this task about? +CONTENT = 'content' +# Calendar Platform: Get a calendar event's description +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' +# Todoist API: Look up a task's due date +DUE_DATE_UTC = 'due_date_utc' +# Attribute: Is this task due today? +DUE_TODAY = 'due_today' +# Calendar Platform: When a calendar event ends +END = 'end' +# Todoist API: Look up a Project/Label/Task ID +ID = 'id' +# Todoist API: Fetch all labels +# Service Call: What are the labels attached to this task? +LABELS = 'labels' +# Todoist API: "Name" value +NAME = 'name' +# Attribute: Is this task overdue? +OVERDUE = 'overdue' +# Attribute: What is this task's priority? +# Todoist API: Get a task's priority +# Service Call: What is this task's priority? +PRIORITY = 'priority' +# Todoist API: Look up the Project ID a Task belongs to +PROJECT_ID = 'project_id' +# Service Call: What Project do you want a Task added to? +PROJECT_NAME = 'project' +# Todoist API: Fetch all Projects +PROJECTS = 'projects' +# Calendar Platform: When does a calendar event start? +START = 'start' +# Calendar Platform: What is the next calendar event about? +SUMMARY = 'summary' +# Todoist API: Fetch all Tasks +TASKS = 'items' + +SERVICE_NEW_TASK = 'todoist_new_task' + +NEW_TASK_SERVICE_SCHEMA = vol.Schema({ + vol.Required(CONTENT): cv.string, + 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.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({ + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_EXTRA_PROJECTS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_PROJECT_DUE_DATE): vol.Coerce(int), + vol.Optional(CONF_PROJECT_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]), + vol.Optional(CONF_PROJECT_LABEL_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]) + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Todoist platform.""" + token = config.get(CONF_TOKEN) + + # Look up IDs based on (lowercase) names. + project_id_lookup = {} + label_id_lookup = {} + + from todoist.api import TodoistAPI + api = TodoistAPI(token) + api.sync() + + # Setup devices: + # Grab all projects. + projects = api.state[PROJECTS] + + # Grab all labels + labels = api.state[LABELS] + + # Add all Todoist-defined projects. + project_devices = [] + for project in projects: + # Project is an object, not a dict! + # Because of that, we convert what we need to a dict. + project_data = { + CONF_NAME: project[NAME], + CONF_ID: project[ID] + } + project_devices.append( + TodoistProjectDevice(hass, project_data, labels, api) + ) + # Cache the names so we can easily look up name->ID. + project_id_lookup[project[NAME].lower()] = project[ID] + + # Cache all label names + for label in labels: + label_id_lookup[label[NAME].lower()] = label[ID] + + # Check config for more projects. + extra_projects = config.get(CONF_EXTRA_PROJECTS) + for project in extra_projects: + # Special filter: By date + project_due_date = project.get(CONF_PROJECT_DUE_DATE) + + # Special filter: By label + project_label_filter = project.get(CONF_PROJECT_LABEL_WHITELIST) + + # Special filter: By name + # Names must be converted into IDs. + project_name_filter = project.get(CONF_PROJECT_WHITELIST) + project_id_filter = [ + project_id_lookup[project_name.lower()] + for project_name in project_name_filter] + + # Create the custom project and add it to the devices array. + project_devices.append( + TodoistProjectDevice( + hass, project, labels, api, project_due_date, + project_label_filter, project_id_filter + ) + ) + + add_devices(project_devices) + + def handle_new_task(call): + """Call when a user creates a new Todoist Task from HASS.""" + project_name = call.data[PROJECT_NAME] + project_id = project_id_lookup[project_name] + + # Create the task + item = api.items.add(call.data[CONTENT], project_id) + + if LABELS in call.data: + task_labels = call.data[LABELS] + label_ids = [ + label_id_lookup[label.lower()] + for label in task_labels] + item.update(labels=label_ids) + + 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: + due = dt.parse_date(call.data[DUE_DATE]) + due_date = datetime(due.year, due.month, due.day) + # Format it in the manner Todoist expects + due_date = dt.as_utc(due_date) + date_format = '%Y-%m-%dT%H:%M' + due_date = datetime.strftime(due_date, date_format) + item.update(due_date_utc=due_date) + # Commit changes + api.commit() + _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) + + hass.services.register(DOMAIN, SERVICE_NEW_TASK, handle_new_task, + schema=NEW_TASK_SERVICE_SCHEMA) + + +class TodoistProjectDevice(CalendarEventDevice): + """A device for getting the next Task from a Todoist Project.""" + + def __init__(self, hass, data, labels, token, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Create the Todoist Calendar Event Device.""" + self.data = TodoistProjectData( + data, labels, token, latest_task_due_date, + whitelisted_labels, whitelisted_projects + ) + + # Set up the calendar side of things + calendar_format = { + CONF_NAME: data[CONF_NAME], + # Set Entity ID to use the name so we can identify calendars + CONF_DEVICE_ID: data[CONF_NAME] + } + + super().__init__(hass, calendar_format) + + def update(self): + """Update all Todoist Calendars.""" + # Set basic calendar data + super().update() + + # Set Todoist-specific data that can't easily be grabbed + self._cal_data[ALL_TASKS] = [ + task[SUMMARY] for task in self.data.all_project_tasks] + + def cleanup(self): + """Clean up all calendar data.""" + super().cleanup() + self._cal_data[ALL_TASKS] = [] + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + + # Add additional attributes. + attributes[DUE_TODAY] = self.data.event[DUE_TODAY] + attributes[OVERDUE] = self.data.event[OVERDUE] + attributes[ALL_TASKS] = self._cal_data[ALL_TASKS] + attributes[PRIORITY] = self.data.event[PRIORITY] + attributes[LABELS] = self.data.event[LABELS] + + return attributes + + +class TodoistProjectData(object): + """ + Class used by the Task Device service object to hold all Todoist Tasks. + + This is analogous to the GoogleCalendarData found in the Google Calendar + component. + + Takes an object with a 'name' field and optionally an 'id' field (either + user-defined or from the Todoist API), a Todoist API token, and an optional + integer specifying the latest number of days from now a task can be due (7 + means everything due in the next week, 0 means today, etc.). + + This object has an exposed 'event' property (used by the Calendar platform + to determine the next calendar event) and an exposed 'update' method (used + by the Calendar platform to poll for new calendar events). + + The 'event' is a representation of a Todoist Task, with defined parameters + of 'due_today' (is the task due today?), 'all_day' (does the task have a + due date?), 'task_labels' (all labels assigned to the task), 'message' + (the content of the task, e.g. 'Fetch Mail'), 'description' (a URL pointing + to the task on the Todoist website), 'end_time' (what time the event is + due), 'start_time' (what time this event was last updated), 'overdue' (is + the task past its due date?), 'priority' (1-4, how important the task is, + with 4 being the most important), and 'all_tasks' (all tasks in this + project, sorted by how important they are). + + 'offset_reached', 'location', and 'friendly_name' are defined by the + platform itself, but are not used by this component at all. + + The 'update' method polls the Todoist API for new projects/tasks, as well + as any updates to current projects/tasks. This is throttled to every + MIN_TIME_BETWEEN_UPDATES minutes. + """ + + def __init__(self, project_data, labels, api, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Initialize a Todoist Project.""" + self.event = None + + self._api = api + self._name = project_data.get(CONF_NAME) + # If no ID is defined, fetch all tasks. + self._id = project_data.get(CONF_ID) + + # All labels the user has defined, for easy lookup. + self._labels = labels + # Not tracked: order, indent, comment_count. + + self.all_project_tasks = [] + + # The latest date a task can be due (for making lists of everything + # due today, or everything due in the next week, for example). + if latest_task_due_date is not None: + self._latest_due_date = dt.utcnow() + timedelta( + days=latest_task_due_date) + else: + self._latest_due_date = None + + # Only tasks with one of these labels will be included. + if whitelisted_labels is not None: + self._label_whitelist = whitelisted_labels + else: + self._label_whitelist = [] + + # This project includes only projects with these names. + if whitelisted_projects is not None: + self._project_id_whitelist = whitelisted_projects + else: + self._project_id_whitelist = [] + + def create_todoist_task(self, data): + """ + Create a dictionary based on a Task passed from the Todoist API. + + Will return 'None' if the task is to be filtered out. + """ + task = {} + # Fields are required to be in all returned task objects. + task[SUMMARY] = data[CONTENT] + task[COMPLETED] = data[CHECKED] == 1 + task[PRIORITY] = data[PRIORITY] + task[DESCRIPTION] = 'https://todoist.com/showTask?id={}'.format( + data[ID]) + + # All task Labels (optional parameter). + task[LABELS] = [ + label[NAME].lower() for label in self._labels + if label[ID] in data[LABELS]] + + if self._label_whitelist and ( + not any(label in task[LABELS] + for label in self._label_whitelist)): + # We're not on the whitelist, return invalid task. + return None + + # Due dates (optional parameter). + # The due date is the END date -- the task cannot be completed + # past this time. + # That means that the START date is the earliest time one can + # complete the task. + # Generally speaking, that means right now. + task[START] = dt.utcnow() + if data[DUE_DATE_UTC] is not None: + due_date = data[DUE_DATE_UTC] + + # Due dates are represented in RFC3339 format, in UTC. + # Home Assistant exclusively uses UTC, so it'll + # handle the conversion. + time_format = '%a %d %b %Y %H:%M:%S %z' + # HASS' built-in parse time function doesn't like + # Todoist's time format; strptime has to be used. + task[END] = datetime.strptime(due_date, time_format) + + if self._latest_due_date is not None and ( + task[END] > self._latest_due_date): + # This task is out of range of our due date; + # it shouldn't be counted. + return None + + task[DUE_TODAY] = task[END].date() == datetime.today().date() + + # Special case: Task is overdue. + if task[END] <= task[START]: + task[OVERDUE] = True + # Set end time to the current time plus 1 hour. + # We're pretty much guaranteed to update within that 1 hour, + # so it should be fine. + task[END] = task[START] + timedelta(hours=1) + else: + task[OVERDUE] = False + else: + # If we ask for everything due before a certain date, don't count + # things which have no due dates. + if self._latest_due_date is not None: + return None + + # Define values for tasks without due dates + task[END] = None + task[ALL_DAY] = True + task[DUE_TODAY] = False + task[OVERDUE] = False + + # Not tracked: id, comments, project_id order, indent, recurring. + return task + + @staticmethod + def select_best_task(project_tasks): + """ + Search through a list of events for the "best" event to select. + + The "best" event is determined by the following criteria: + * A proposed event must not be completed + * A proposed event must have an end date (otherwise we go with + the event at index 0, selected above) + * A proposed event must be on the same day or earlier as our + current event + * If a proposed event is an earlier day than what we have so + far, select it + * If a proposed event is on the same day as our current event + and the proposed event has a higher priority than our current + event, select it + * If a proposed event is on the same day as our current event, + has the same priority as our current event, but is due earlier + in the day, select it + """ + # Start at the end of the list, so if tasks don't have a due date + # the newest ones are the most important. + + event = project_tasks[-1] + + for proposed_event in project_tasks: + if event == proposed_event: + continue + if proposed_event[COMPLETED]: + # Event is complete! + continue + if proposed_event[END] is None: + # No end time: + if event[END] is None and ( + proposed_event[PRIORITY] < event[PRIORITY]): + # They also have no end time, + # but we have a higher priority. + event = proposed_event + continue + else: + continue + elif event[END] is None: + # We have an end time, they do not. + event = proposed_event + continue + if proposed_event[END].date() > event[END].date(): + # Event is too late. + continue + elif proposed_event[END].date() < event[END].date(): + # Event is earlier than current, select it. + event = proposed_event + continue + else: + if proposed_event[PRIORITY] > event[PRIORITY]: + # Proposed event has a higher priority. + event = proposed_event + continue + elif proposed_event[PRIORITY] == event[PRIORITY] and ( + proposed_event[END] < event[END]): + event = proposed_event + continue + return event + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + if self._id is None: + project_task_data = [ + task for task in self._api.state[TASKS] + if not self._project_id_whitelist or + task[PROJECT_ID] in self._project_id_whitelist] + else: + project_task_data = self._api.projects.get_data(self._id)[TASKS] + + # If we have no data, we can just return right away. + if not project_task_data: + self.event = None + return True + + # Keep an updated list of all tasks in this project. + project_tasks = [] + + for task in project_task_data: + todoist_task = self.create_todoist_task(task) + if todoist_task is not None: + # A None task means it is invalid for this project + project_tasks.append(todoist_task) + + if not project_tasks: + # 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: + best_task = self.select_best_task(project_tasks) + _LOGGER.debug("Found Todoist Task: %s", best_task[SUMMARY]) + project_tasks.remove(best_task) + self.all_project_tasks.append(best_task) + + self.event = self.all_project_tasks[0] + + # Convert datetime to a string again + if self.event is not None: + if self.event[START] is not None: + self.event[START] = { + DATETIME: self.event[START].strftime(DATE_STR_FORMAT) + } + if self.event[END] is not None: + self.event[END] = { + DATETIME: self.event[END].strftime(DATE_STR_FORMAT) + } + else: + # HASS gets cranky if a calendar event never ends + # Let's set our "due date" to tomorrow + self.event[END] = { + DATETIME: ( + datetime.utcnow() + timedelta(days=1) + ).strftime(DATE_STR_FORMAT) + } + _LOGGER.debug("Updated %s", self._name) + return True diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index a7d778d99aa79..60f8979bb16d9 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -6,41 +6,44 @@ https://home-assistant.io/components/camera/ """ import asyncio +import base64 import collections from contextlib import suppress from datetime import timedelta import logging import hashlib from random import SystemRandom -import os -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.config import load_yaml_config_file +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.helpers.event import async_track_time_interval +from homeassistant.components import websocket_api import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -SERVICE_EN_MOTION = 'enable_motion_detection' -SERVICE_DISEN_MOTION = 'disable_motion_detection' DOMAIN = 'camera' DEPENDENCIES = ['http'] + +_LOGGER = logging.getLogger(__name__) + +SERVICE_ENABLE_MOTION = 'enable_motion_detection' +SERVICE_DISABLE_MOTION = 'disable_motion_detection' +SERVICE_SNAPSHOT = 'snapshot' + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + '.{}' +ATTR_FILENAME = 'filename' + STATE_RECORDING = 'recording' STATE_STREAMING = 'streaming' STATE_IDLE = 'idle' @@ -51,17 +54,38 @@ 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, }) +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): """Enable Motion Detection.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None hass.async_add_job(hass.services.async_call( - DOMAIN, SERVICE_EN_MOTION, data)) + DOMAIN, SERVICE_ENABLE_MOTION, data)) @bind_hass @@ -69,94 +93,125 @@ def disable_motion_detection(hass, entity_id=None): """Disable Motion Detection.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None hass.async_add_job(hass.services.async_call( - DOMAIN, SERVICE_DISEN_MOTION, data)) + DOMAIN, SERVICE_DISABLE_MOTION, data)) -@asyncio.coroutine -def async_get_image(hass, entity_id, timeout=10): - """Fetch a image from a camera entity.""" - websession = async_get_clientsession(hass) - state = hass.states.get(entity_id) - - if state is None: - raise HomeAssistantError( - "No entity '{0}' for grab a image".format(entity_id)) - - url = "{0}{1}".format( - hass.config.api.base_url, - state.attributes.get(ATTR_ENTITY_PICTURE) - ) +@bind_hass +def async_snapshot(hass, filename, entity_id=None): + """Make a snapshot from a camera.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + data[ATTR_FILENAME] = filename - try: - with async_timeout.timeout(timeout, loop=hass.loop): - response = yield from websession.get(url) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_SNAPSHOT, data)) + + +@bind_hass +async def async_get_image(hass, entity_id, timeout=10): + """Fetch an image from a camera entity.""" + component = hass.data.get(DOMAIN) + + if component is None: + raise HomeAssistantError('Camera component not setup') - if response.status != 200: - raise HomeAssistantError("Error {0} on {1}".format( - response.status, url)) + camera = component.get_entity(entity_id) - image = yield from response.read() - return image + if camera is None: + raise HomeAssistantError('Camera not found') - except (asyncio.TimeoutError, aiohttp.ClientError): - raise HomeAssistantError("Can't connect to {0}".format(url)) + with suppress(asyncio.CancelledError, asyncio.TimeoutError): + with async_timeout.timeout(timeout, loop=hass.loop): + image = await camera.async_camera_image() + + if image: + return Image(camera.content_type, image) + + 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) - - hass.http.register_view(CameraImageView(component.entities)) - hass.http.register_view(CameraMjpegStream(component.entities)) + 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) @callback def update_tokens(time): """Update tokens of the entities.""" - for entity in component.entities.values(): + for entity in component.entities: entity.async_update_token() hass.async_add_job(entity.async_update_ha_state()) - async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL) + hass.helpers.event.async_track_time_interval( + update_tokens, TOKEN_CHANGE_INTERVAL) @asyncio.coroutine def async_handle_camera_service(service): """Handle calls to the camera services.""" target_cameras = component.async_extract_from_service(service) + update_tasks = [] for camera in target_cameras: - if service.service == SERVICE_EN_MOTION: + if service.service == SERVICE_ENABLE_MOTION: yield from camera.async_enable_motion_detection() - elif service.service == SERVICE_DISEN_MOTION: + elif service.service == SERVICE_DISABLE_MOTION: yield from camera.async_disable_motion_detection() - update_tasks = [] - for camera in target_cameras: if not camera.should_poll: continue - - update_coro = hass.async_add_job( - camera.async_update_ha_state(True)) - if hasattr(camera, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(camera.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) + @asyncio.coroutine + def async_handle_snapshot_service(service): + """Handle snapshot services calls.""" + target_cameras = component.async_extract_from_service(service) + filename = service.data[ATTR_FILENAME] + filename.hass = hass + for camera in target_cameras: + snapshot_file = filename.async_render( + variables={ATTR_ENTITY_ID: camera}) + + # check if we allow to access to that file + if not hass.config.is_allowed_path(snapshot_file): + _LOGGER.error( + "Can't write %s, no access to path!", snapshot_file) + continue + + image = yield from camera.async_camera_image() + + def _write_image(to_file, image_data): + """Executor helper to write image.""" + with open(to_file, 'wb') as img_file: + img_file.write(image_data) + + try: + yield from hass.async_add_job( + _write_image, snapshot_file, image) + except OSError as err: + _LOGGER.error("Can't write image to file: %s", err) + + hass.services.async_register( + DOMAIN, SERVICE_ENABLE_MOTION, async_handle_camera_service, + schema=CAMERA_SERVICE_SCHEMA) hass.services.async_register( - DOMAIN, SERVICE_EN_MOTION, async_handle_camera_service, - descriptions.get(SERVICE_EN_MOTION), schema=CAMERA_SERVICE_SCHEMA) + DOMAIN, SERVICE_DISABLE_MOTION, async_handle_camera_service, + schema=CAMERA_SERVICE_SCHEMA) hass.services.async_register( - DOMAIN, SERVICE_DISEN_MOTION, async_handle_camera_service, - descriptions.get(SERVICE_DISEN_MOTION), schema=CAMERA_SERVICE_SCHEMA) + DOMAIN, SERVICE_SNAPSHOT, async_handle_snapshot_service, + schema=CAMERA_SERVICE_SNAPSHOT) return True @@ -201,6 +256,11 @@ def model(self): """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() @@ -212,21 +272,19 @@ def async_camera_image(self): """ 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) - def write(img_bytes): + async def write_to_mjpeg_stream(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( @@ -237,22 +295,21 @@ def write(img_bytes): 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: - 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: - write(img_bytes) + await write_to_mjpeg_stream(img_bytes) last_image = img_bytes - yield from response.drain() - yield from asyncio.sleep(.5) + await asyncio.sleep(interval) except asyncio.CancelledError: _LOGGER.debug("Stream closed by frontend.") @@ -260,7 +317,16 @@ def write(img_bytes): 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): @@ -290,20 +356,20 @@ def async_disable_motion_detection(self): @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): @@ -318,14 +384,14 @@ class CameraView(HomeAssistantView): requires_auth = False - def __init__(self, entities): + def __init__(self, component): """Initialize a basic camera view.""" - self.entities = entities + self.component = component @asyncio.coroutine def get(self, request, entity_id): """Start a GET request.""" - camera = self.entities.get(entity_id) + camera = self.component.get_entity(entity_id) if camera is None: status = 404 if request[KEY_AUTHENTICATED] else 401 @@ -372,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/abode.py b/homeassistant/components/camera/abode.py new file mode 100644 index 0000000000000..ee739810a6142 --- /dev/null +++ b/homeassistant/components/camera/abode.py @@ -0,0 +1,101 @@ +""" +This component provides HA camera support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.abode/ +""" +import asyncio +import logging + +from datetime import timedelta +import requests + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.camera import Camera +from homeassistant.util import Throttle + + +DEPENDENCIES = ['abode'] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode camera devices.""" + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA): + if data.is_excluded(device): + continue + + devices.append(AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeCamera(AbodeDevice, Camera): + """Representation of an Abode camera.""" + + def __init__(self, data, device, event): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, data, device) + Camera.__init__(self) + self._event = event + self._response = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + yield from super().async_added_to_hass() + + self.hass.async_add_job( + self._data.abode.events.add_timeline_callback, + self._event, self._capture_callback + ) + + def capture(self): + """Request a new image capture.""" + return self._device.capture() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def refresh_image(self): + """Find a new image on the timeline.""" + if self._device.refresh_image(): + self.get_image() + + def get_image(self): + """Attempt to download the most recent capture.""" + if self._device.image_url: + try: + self._response = requests.get( + self._device.image_url, stream=True) + + self._response.raise_for_status() + except requests.HTTPError as err: + _LOGGER.warning("Failed to get camera image: %s", err) + self._response = None + else: + self._response = None + + def camera_image(self): + """Get a camera image.""" + self.refresh_image() + + if self._response: + return self._response.content + + return None + + def _capture_callback(self, capture): + """Update the image with the device then refresh device.""" + self._device.update_image_location(capture) + self.get_image() + self.schedule_update_ha_state() diff --git a/homeassistant/components/camera/amcrest.py b/homeassistant/components/camera/amcrest.py index 51b8ff13906f3..3c63e56b3191f 100644 --- a/homeassistant/components/camera/amcrest.py +++ b/homeassistant/components/camera/amcrest.py @@ -8,9 +8,10 @@ import logging from homeassistant.components.amcrest import ( - STREAM_SOURCE_LIST, TIMEOUT) + DATA_AMCREST, STREAM_SOURCE_LIST, TIMEOUT) from homeassistant.components.camera import Camera from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import CONF_NAME from homeassistant.helpers.aiohttp_client import ( async_get_clientsession, async_aiohttp_proxy_web, async_aiohttp_proxy_stream) @@ -26,21 +27,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if discovery_info is None: return - device = discovery_info['device'] - authentication = discovery_info['authentication'] - ffmpeg_arguments = discovery_info['ffmpeg_arguments'] - name = discovery_info['name'] - resolution = discovery_info['resolution'] - stream_source = discovery_info['stream_source'] - - async_add_devices([ - AmcrestCam(hass, - name, - device, - authentication, - ffmpeg_arguments, - stream_source, - resolution)], True) + device_name = discovery_info[CONF_NAME] + amcrest = hass.data[DATA_AMCREST][device_name] + + async_add_devices([AmcrestCam(hass, amcrest)], True) return True @@ -48,21 +38,20 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class AmcrestCam(Camera): """An implementation of an Amcrest IP camera.""" - def __init__(self, hass, name, camera, authentication, - ffmpeg_arguments, stream_source, resolution): + def __init__(self, hass, amcrest): """Initialize an Amcrest camera.""" super(AmcrestCam, self).__init__() - self._name = name - self._camera = camera + self._name = amcrest.name + self._camera = amcrest.device self._base_url = self._camera.get_base_url() self._ffmpeg = hass.data[DATA_FFMPEG] - self._ffmpeg_arguments = ffmpeg_arguments - self._stream_source = stream_source - self._resolution = resolution - self._token = self._auth = authentication + self._ffmpeg_arguments = amcrest.ffmpeg_arguments + self._stream_source = amcrest.stream_source + self._resolution = amcrest.resolution + self._token = self._auth = amcrest.authentication def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data response = self._camera.snapshot(channel=self._resolution) return response.data diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index 80833e34b207e..f3e70c2bdd74c 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -1,35 +1,55 @@ """ -This component provides basic support for Netgear Arlo IP cameras. +Support for Netgear Arlo IP cameras. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.arlo/ """ import asyncio import logging +from datetime import timedelta import voluptuous as vol -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +import homeassistant.helpers.config_validation as cv from homeassistant.components.arlo import DEFAULT_BRAND, DATA_ARLO from homeassistant.components.camera import Camera, PLATFORM_SCHEMA from homeassistant.components.ffmpeg import DATA_FFMPEG - -DEPENDENCIES = ['arlo', 'ffmpeg'] +from homeassistant.const import ATTR_BATTERY_LEVEL +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream _LOGGER = logging.getLogger(__name__) -CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' +SCAN_INTERVAL = timedelta(seconds=90) + ARLO_MODE_ARMED = 'armed' ARLO_MODE_DISARMED = 'disarmed' +ATTR_BRIGHTNESS = 'brightness' +ATTR_FLIPPED = 'flipped' +ATTR_MIRRORED = 'mirrored' +ATTR_MOTION = 'motion_detection_sensitivity' +ATTR_POWERSAVE = 'power_save_mode' +ATTR_SIGNAL_STRENGTH = 'signal_strength' +ATTR_UNSEEN_VIDEOS = 'unseen_videos' +ATTR_LAST_REFRESH = 'last_refresh' + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +DEPENDENCIES = ['arlo', 'ffmpeg'] + +POWERSAVE_MODE_MAPPING = { + 1: 'best_battery_life', + 2: 'optimized', + 3: 'best_video' +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): + cv.string, }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Arlo IP Camera.""" arlo = hass.data.get(DATA_ARLO) if not arlo: @@ -39,7 +59,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): for camera in arlo.cameras: cameras.append(ArloCam(hass, camera, config)) - async_add_devices(cameras, True) + add_devices(cameras, True) class ArloCam(Camera): @@ -53,6 +73,11 @@ def __init__(self, hass, camera, device_info): self._motion_status = False self._ffmpeg = hass.data[DATA_FFMPEG] self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) + self._last_refresh = None + if self._camera.base_station: + self._camera.base_station.refresh_rate = \ + SCAN_INTERVAL.total_seconds() + self.attrs = {} def camera_image(self): """Return a still image response from the camera.""" @@ -80,14 +105,31 @@ def name(self): """Return the name of this camera.""" return self._name + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + name: value for name, value in ( + (ATTR_BATTERY_LEVEL, self._camera.battery_level), + (ATTR_BRIGHTNESS, self._camera.brightness), + (ATTR_FLIPPED, self._camera.flip_state), + (ATTR_MIRRORED, self._camera.mirror_state), + (ATTR_MOTION, self._camera.motion_detection_sensitivity), + (ATTR_POWERSAVE, POWERSAVE_MODE_MAPPING.get( + self._camera.powersave_mode)), + (ATTR_SIGNAL_STRENGTH, self._camera.signal_strength), + (ATTR_UNSEEN_VIDEOS, self._camera.unseen_videos), + ) if value is not None + } + @property def model(self): - """Camera model.""" + """Return the camera model.""" return self._camera.model_id @property def brand(self): - """Camera brand.""" + """Return the camera brand.""" return DEFAULT_BRAND @property @@ -97,7 +139,7 @@ def should_poll(self): @property def motion_detection_enabled(self): - """Camera Motion Detection Status.""" + """Return the camera motion detection status.""" return self._motion_status def set_base_station_mode(self, mode): @@ -105,7 +147,7 @@ def set_base_station_mode(self, mode): # Get the list of base stations identified by library base_stations = self.hass.data[DATA_ARLO].base_stations - # Some Arlo cameras does not have basestation + # Some Arlo cameras does not have base station # So check if there is base station detected first # if yes, then choose the primary base station # Set the mode on the chosen base station @@ -122,3 +164,7 @@ def disable_motion_detection(self): """Disable the motion detection in base station (Disarm).""" self._motion_status = False self.set_base_station_mode(ARLO_MODE_DISARMED) + + def update(self): + """Add an attribute-update task to the executor pool.""" + self._camera.update() diff --git a/homeassistant/components/camera/august.py b/homeassistant/components/camera/august.py new file mode 100644 index 0000000000000..d3bc080bfc69e --- /dev/null +++ b/homeassistant/components/camera/august.py @@ -0,0 +1,76 @@ +""" +Support for August camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.august/ +""" +from datetime import timedelta + +import requests + +from homeassistant.components.august import DATA_AUGUST, DEFAULT_TIMEOUT +from homeassistant.components.camera import Camera + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up August cameras.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for doorbell in data.doorbells: + devices.append(AugustCamera(data, doorbell, DEFAULT_TIMEOUT)) + + add_devices(devices, True) + + +class AugustCamera(Camera): + """An implementation of a Canary security camera.""" + + def __init__(self, data, doorbell, timeout): + """Initialize a Canary security camera.""" + super().__init__() + self._data = data + self._doorbell = doorbell + self._timeout = timeout + self._image_url = None + self._image_content = None + + @property + def name(self): + """Return the name of this device.""" + return self._doorbell.device_name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._doorbell.has_subscription + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return True + + @property + def brand(self): + """Return the camera brand.""" + return 'August' + + @property + def model(self): + """Return the camera model.""" + return 'Doorbell' + + def camera_image(self): + """Return bytes of camera image.""" + latest = self._data.get_doorbell_detail(self._doorbell.device_id) + + if self._image_url is not latest.image_url: + self._image_url = latest.image_url + self._image_content = requests.get(self._image_url, + timeout=self._timeout).content + + return self._image_content diff --git a/homeassistant/components/camera/axis.py b/homeassistant/components/camera/axis.py index b0295b9ee34e7..51c3bc89b0536 100644 --- a/homeassistant/components/camera/axis.py +++ b/homeassistant/components/camera/axis.py @@ -6,12 +6,12 @@ """ import logging -from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, - CONF_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) from homeassistant.components.camera.mjpeg import ( CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera) -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.const import ( + CONF_AUTHENTICATION, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_USERNAME, HTTP_DIGEST_AUTHENTICATION) +from homeassistant.helpers.dispatcher import dispatcher_connect _LOGGER = logging.getLogger(__name__) @@ -19,38 +19,43 @@ DEPENDENCIES = [DOMAIN] -def _get_image_url(host, mode): +def _get_image_url(host, port, mode): + """Set the URL to get the image.""" if mode == 'mjpeg': - return 'http://{}/axis-cgi/mjpg/video.cgi'.format(host) + return 'http://{}:{}/axis-cgi/mjpg/video.cgi'.format(host, port) elif mode == 'single': - return 'http://{}/axis-cgi/jpg/image.cgi'.format(host) + return 'http://{}:{}/axis-cgi/jpg/image.cgi'.format(host, port) def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup Axis camera.""" - config = { + """Set up the Axis camera.""" + camera_config = { CONF_NAME: discovery_info[CONF_NAME], CONF_USERNAME: discovery_info[CONF_USERNAME], CONF_PASSWORD: discovery_info[CONF_PASSWORD], - CONF_MJPEG_URL: _get_image_url(discovery_info[CONF_HOST], 'mjpeg'), - CONF_STILL_IMAGE_URL: _get_image_url(discovery_info[CONF_HOST], - 'single'), + CONF_MJPEG_URL: _get_image_url( + discovery_info[CONF_HOST], str(discovery_info[CONF_PORT]), + 'mjpeg'), + CONF_STILL_IMAGE_URL: _get_image_url( + discovery_info[CONF_HOST], str(discovery_info[CONF_PORT]), + 'single'), CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, } - add_devices([AxisCamera(hass, config)]) + add_devices([AxisCamera( + hass, camera_config, str(discovery_info[CONF_PORT]))]) class AxisCamera(MjpegCamera): - """AxisCamera class.""" + """Representation of a Axis camera.""" - def __init__(self, hass, config): + def __init__(self, hass, config, port): """Initialize Axis Communications camera component.""" super().__init__(hass, config) - async_dispatcher_connect(hass, - DOMAIN + '_' + config[CONF_NAME] + '_new_ip', - self._new_ip) + self.port = port + dispatcher_connect( + hass, DOMAIN + '_' + config[CONF_NAME] + '_new_ip', self._new_ip) def _new_ip(self, host): """Set new IP for video stream.""" - self._mjpeg_url = _get_image_url(host, 'mjpeg') - self._still_image_url = _get_image_url(host, 'mjpeg') + self._mjpeg_url = _get_image_url(host, self.port, 'mjpeg') + self._still_image_url = _get_image_url(host, self.port, 'single') diff --git a/homeassistant/components/camera/blink.py b/homeassistant/components/camera/blink.py index bca4fafec4faf..8475ca8fd1e82 100644 --- a/homeassistant/components/camera/blink.py +++ b/homeassistant/components/camera/blink.py @@ -4,21 +4,21 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.blink/ """ +from datetime import timedelta import logging -from datetime import timedelta import requests from homeassistant.components.blink import DOMAIN from homeassistant.components.camera import Camera from homeassistant.util import Throttle +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ['blink'] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) -_LOGGER = logging.getLogger(__name__) - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a Blink Camera.""" @@ -45,7 +45,7 @@ def __init__(self, hass, config, data, name): self.notifications = self.data.cameras[self._name].notifications self.response = None - _LOGGER.info("Initialized blink camera %s", self._name) + _LOGGER.debug("Initialized blink camera %s", self._name) @property def name(self): @@ -55,7 +55,7 @@ def name(self): @Throttle(MIN_TIME_BETWEEN_UPDATES) def request_image(self): """Request a new image from Blink servers.""" - _LOGGER.info("Requesting new image from blink servers") + _LOGGER.debug("Requesting new image from blink servers") image_url = self.check_for_motion() header = self.data.cameras[self._name].header self.response = requests.get(image_url, headers=header, stream=True) @@ -68,7 +68,7 @@ def check_for_motion(self): # We detected motion at some point self.data.last_motion() self.notifications = notifs - # returning motion image currently not working + # Returning motion image currently not working # return self.data.cameras[self._name].motion['image'] elif notifs < self.notifications: self.notifications = notifs @@ -76,6 +76,6 @@ def check_for_motion(self): return self.data.camera_thumbs[self._name] def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" self.request_image() return self.response.content diff --git a/homeassistant/components/camera/bloomsky.py b/homeassistant/components/camera/bloomsky.py index c3b4775b59373..ef70692215dfb 100644 --- a/homeassistant/components/camera/bloomsky.py +++ b/homeassistant/components/camera/bloomsky.py @@ -9,7 +9,6 @@ import requests from homeassistant.components.camera import Camera -from homeassistant.loader import get_component DEPENDENCIES = ['bloomsky'] @@ -17,7 +16,7 @@ # 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/canary.py b/homeassistant/components/camera/canary.py new file mode 100644 index 0000000000000..a230e0f6d4a21 --- /dev/null +++ b/homeassistant/components/camera/canary.py @@ -0,0 +1,112 @@ +""" +Support for Canary camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.canary/ +""" +import asyncio +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.canary import DATA_CANARY, DEFAULT_TIMEOUT +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.util import Throttle + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +DEPENDENCIES = ['canary', 'ffmpeg'] + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_SESSION_RENEW = timedelta(seconds=90) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Canary sensors.""" + data = hass.data[DATA_CANARY] + devices = [] + + for location in data.locations: + for device in location.devices: + if device.is_online: + devices.append( + CanaryCamera(hass, data, location, device, DEFAULT_TIMEOUT, + config.get(CONF_FFMPEG_ARGUMENTS))) + + add_devices(devices, True) + + +class CanaryCamera(Camera): + """An implementation of a Canary security camera.""" + + def __init__(self, hass, data, location, device, timeout, ffmpeg_args): + """Initialize a Canary security camera.""" + super().__init__() + + self._ffmpeg = hass.data[DATA_FFMPEG] + self._ffmpeg_arguments = ffmpeg_args + self._data = data + self._location = location + self._device = device + self._timeout = timeout + self._live_stream_session = None + + @property + def name(self): + """Return the name of this device.""" + return self._device.name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._location.is_recording + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return not self._location.is_recording + + @asyncio.coroutine + def async_camera_image(self): + """Return a still image response from the camera.""" + self.renew_live_stream_session() + + from haffmpeg import ImageFrame, IMAGE_JPEG + ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) + image = yield from asyncio.shield(ffmpeg.get_image( + self._live_stream_session.live_stream_url, + output_format=IMAGE_JPEG, + extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) + return image + + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + if self._live_stream_session is None: + return + + from haffmpeg import CameraMjpeg + stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) + yield from stream.open_camera( + self._live_stream_session.live_stream_url, + extra_cmd=self._ffmpeg_arguments) + + yield from async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + yield from stream.close() + + @Throttle(MIN_TIME_BETWEEN_SESSION_RENEW) + def renew_live_stream_session(self): + """Renew live stream session.""" + self._live_stream_session = self._data.get_live_stream_session( + self._device) diff --git a/homeassistant/components/camera/demo_0.jpg b/homeassistant/components/camera/demo_0.jpg index ff87d5179f836..f062b26bad798 100644 Binary files a/homeassistant/components/camera/demo_0.jpg and b/homeassistant/components/camera/demo_0.jpg differ diff --git a/homeassistant/components/camera/demo_1.jpg b/homeassistant/components/camera/demo_1.jpg index 06166fffa859d..a349f22b1523e 100644 Binary files a/homeassistant/components/camera/demo_1.jpg and b/homeassistant/components/camera/demo_1.jpg differ diff --git a/homeassistant/components/camera/demo_2.jpg b/homeassistant/components/camera/demo_2.jpg index 71356479ab08c..e21d7457ebf01 100644 Binary files a/homeassistant/components/camera/demo_2.jpg and b/homeassistant/components/camera/demo_2.jpg differ diff --git a/homeassistant/components/camera/demo_3.jpg b/homeassistant/components/camera/demo_3.jpg index 06166fffa859d..a349f22b1523e 100644 Binary files a/homeassistant/components/camera/demo_3.jpg and b/homeassistant/components/camera/demo_3.jpg differ diff --git a/homeassistant/components/camera/doorbird.py b/homeassistant/components/camera/doorbird.py new file mode 100644 index 0000000000000..034ddc2fabbe0 --- /dev/null +++ b/homeassistant/components/camera/doorbird.py @@ -0,0 +1,83 @@ +""" +Support for viewing the camera feed from a DoorBird video doorbell. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.doorbird/ +""" +import asyncio +import datetime +import logging + +import aiohttp +import async_timeout + +from homeassistant.components.camera import Camera +from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +DEPENDENCIES = ['doorbird'] + +_CAMERA_LAST_VISITOR = "DoorBird Last Ring" +_CAMERA_LAST_MOTION = "DoorBird Last Motion" +_CAMERA_LIVE = "DoorBird Live" +_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1) +_LAST_MOTION_INTERVAL = datetime.timedelta(minutes=1) +_LIVE_INTERVAL = datetime.timedelta(seconds=1) +_LOGGER = logging.getLogger(__name__) +_TIMEOUT = 10 # seconds + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the DoorBird camera platform.""" + device = hass.data.get(DOORBIRD_DOMAIN) + async_add_devices([ + DoorBirdCamera(device.live_image_url, _CAMERA_LIVE, _LIVE_INTERVAL), + DoorBirdCamera( + device.history_image_url(1, 'doorbell'), _CAMERA_LAST_VISITOR, + _LAST_VISITOR_INTERVAL), + DoorBirdCamera( + device.history_image_url(1, 'motionsensor'), _CAMERA_LAST_MOTION, + _LAST_MOTION_INTERVAL), + ]) + + +class DoorBirdCamera(Camera): + """The camera on a DoorBird device.""" + + def __init__(self, url, name, interval=None): + """Initialize the camera on a DoorBird device.""" + self._url = url + self._name = name + self._last_image = None + self._interval = interval or datetime.timedelta + self._last_update = datetime.datetime.min + super().__init__() + + @property + def name(self): + """Get the name of the camera.""" + return self._name + + @asyncio.coroutine + def async_camera_image(self): + """Pull a still image from the camera.""" + now = datetime.datetime.now() + + if self._last_image and now - self._last_update < self._interval: + return self._last_image + + try: + websession = async_get_clientsession(self.hass) + with async_timeout.timeout(_TIMEOUT, loop=self.hass.loop): + response = yield from websession.get(self._url) + + self._last_image = yield from response.read() + self._last_update = now + return self._last_image + except asyncio.TimeoutError: + _LOGGER.error("Camera image timed out") + return self._last_image + except aiohttp.ClientError as error: + _LOGGER.error("Error getting camera image: %s", error) + return self._last_image diff --git a/homeassistant/components/camera/familyhub.py b/homeassistant/components/camera/familyhub.py new file mode 100644 index 0000000000000..e78d341713b76 --- /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/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index 8ca72a0926140..1bbd263e58555 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -55,9 +55,9 @@ def async_camera_image(self): from haffmpeg import ImageFrame, IMAGE_JPEG ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) - image = yield from ffmpeg.get_image( + image = yield from asyncio.shield(ffmpeg.get_image( self._input, output_format=IMAGE_JPEG, - extra_cmd=self._extra_arguments) + extra_cmd=self._extra_arguments), loop=self.hass.loop) return image @asyncio.coroutine diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 8ea90d5a44e27..15db83d345a93 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyfoscam==1.2'] +REQUIREMENTS = ['libpyfoscam==1.0'] CONF_IP = 'ip' @@ -44,6 +44,8 @@ class FoscamCam(Camera): def __init__(self, device_info): """Initialize a Foscam camera.""" + from libpyfoscam import FoscamCamera + super(FoscamCam, self).__init__() ip_address = device_info.get(CONF_IP) @@ -53,13 +55,11 @@ def __init__(self, device_info): self._name = device_info.get(CONF_NAME) self._motion_status = False - from foscam.foscam import FoscamCamera - - self._foscam_session = FoscamCamera(ip_address, port, self._username, - self._password, verbose=False) + self._foscam_session = FoscamCamera( + ip_address, port, self._username, self._password, verbose=False) def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data # Handle exception if host is not reachable or url failed result, response = self._foscam_session.snap_picture_2() @@ -75,20 +75,20 @@ def motion_detection_enabled(self): def enable_motion_detection(self): """Enable motion detection in camera.""" - ret, err = self._foscam_session.enable_motion_detection() - if ret == FOSCAM_COMM_ERROR: - _LOGGER.debug("Unable to communicate with Foscam Camera: %s", err) - self._motion_status = True - else: + try: + ret = self._foscam_session.enable_motion_detection() + self._motion_status = ret == FOSCAM_COMM_ERROR + except TypeError: + _LOGGER.debug("Communication problem") self._motion_status = False def disable_motion_detection(self): """Disable motion detection.""" - ret, err = self._foscam_session.disable_motion_detection() - if ret == FOSCAM_COMM_ERROR: - _LOGGER.debug("Unable to communicate with Foscam Camera: %s", err) - self._motion_status = True - else: + try: + ret = self._foscam_session.disable_motion_detection() + self._motion_status = ret == FOSCAM_COMM_ERROR + except TypeError: + _LOGGER.debug("Communication problem") self._motion_status = False @property diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index 3f8c4bedc75a9..e11bd599e45e7 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -21,13 +21,14 @@ PLATFORM_SCHEMA, DEFAULT_CONTENT_TYPE, Camera) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers import config_validation as cv -from homeassistant.util.async import run_coroutine_threadsafe +from homeassistant.util.async_ import run_coroutine_threadsafe _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 @@ 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 @@ def __init__(self, hass, device_info): 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 @@ def __init__(self, hass, device_info): 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 95d24c7d42efc..95eade48568f4 100644 --- a/homeassistant/components/camera/local_file.py +++ b/homeassistant/components/camera/local_file.py @@ -11,31 +11,44 @@ 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 @@ def __init__(self, name, file_path): 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 @@ def camera_image(self): _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/mjpeg.py b/homeassistant/components/camera/mjpeg.py index 6168eb81939a2..35d30104f6e66 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -119,6 +119,8 @@ def camera_image(self): else: req = requests.get(self._mjpeg_url, stream=True, timeout=10) + # https://github.com/PyCQA/pylint/issues/1437 + # pylint: disable=no-member with closing(req) as response: return extract_image_from_mjpeg(response.iter_content(102400)) diff --git a/homeassistant/components/camera/mqtt.py b/homeassistant/components/camera/mqtt.py old mode 100755 new mode 100644 index 8d72ec35a2888..b2a27230a02d5 --- a/homeassistant/components/camera/mqtt.py +++ b/homeassistant/components/camera/mqtt.py @@ -19,7 +19,6 @@ _LOGGER = logging.getLogger(__name__) CONF_TOPIC = 'topic' - DEFAULT_NAME = 'MQTT Camera' DEPENDENCIES = ['mqtt'] @@ -33,9 +32,13 @@ @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): @@ -60,11 +63,9 @@ def name(self): """Return the name of this camera.""" return self._name + @asyncio.coroutine def async_added_to_hass(self): - """Subscribe MQTT events. - - This method must be run in the event loop and returns a coroutine. - """ + """Subscribe MQTT events.""" @callback def message_received(topic, payload, qos): """Handle new MQTT messages.""" diff --git a/homeassistant/components/camera/netatmo.py b/homeassistant/components/camera/netatmo.py index c1ec2db0a08ba..bf2dfe39bd8b5 100644 --- a/homeassistant/components/camera/netatmo.py +++ b/homeassistant/components/camera/netatmo.py @@ -12,7 +12,6 @@ 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 @@ # 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 @@ -64,10 +63,6 @@ def __init__(self, data, camera_name, home, camera_type, verify_ssl): self._name = home + ' / ' + camera_name else: self._name = camera_name - camera_id = data.camera_data.cameraByName( - camera=camera_name, home=home)['id'] - self._unique_id = "Welcome_camera {0} - {1}".format( - self._name, camera_id) self._vpnurl, self._localurl = self._data.camera_data.cameraUrls( camera=camera_name ) @@ -114,8 +109,3 @@ def model(self): elif self._cameratype == "NACamera": return "Welcome" return None - - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return self._unique_id diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index 711eb75a7445e..3ae47ba5dee9d 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -11,13 +11,15 @@ import voluptuous as vol from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT) -from homeassistant.components.camera import Camera, PLATFORM_SCHEMA + CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, + ATTR_ENTITY_ID) +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA, DOMAIN from homeassistant.components.ffmpeg import ( - DATA_FFMPEG) + DATA_FFMPEG, CONF_EXTRA_ARGUMENTS) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream) +from homeassistant.helpers.service import extract_entity_ids _LOGGER = logging.getLogger(__name__) @@ -31,6 +33,26 @@ DEFAULT_PORT = 5000 DEFAULT_USERNAME = 'admin' DEFAULT_PASSWORD = '888888' +DEFAULT_ARGUMENTS = '-q:v 2' +DEFAULT_PROFILE = 0 + +CONF_PROFILE = "profile" + +ATTR_PAN = "pan" +ATTR_TILT = "tilt" +ATTR_ZOOM = "zoom" + +DIR_UP = "UP" +DIR_DOWN = "DOWN" +DIR_LEFT = "LEFT" +DIR_RIGHT = "RIGHT" +ZOOM_OUT = "ZOOM_OUT" +ZOOM_IN = "ZOOM_IN" + +SERVICE_PTZ = "onvif_ptz" + +ONVIF_DATA = "onvif" +ENTITIES = "entities" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -38,65 +60,172 @@ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_EXTRA_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string, + vol.Optional(CONF_PROFILE, default=DEFAULT_PROFILE): + vol.All(vol.Coerce(int), vol.Range(min=0)), +}) + +SERVICE_PTZ_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, + ATTR_PAN: vol.In([DIR_LEFT, DIR_RIGHT]), + ATTR_TILT: vol.In([DIR_UP, DIR_DOWN]), + ATTR_ZOOM: vol.In([ZOOM_OUT, ZOOM_IN]) }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a ONVIF camera.""" if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_HOST)): return - async_add_devices([ONVIFCamera(hass, config)]) - -class ONVIFCamera(Camera): + def handle_ptz(service): + """Handle PTZ service call.""" + pan = service.data.get(ATTR_PAN, None) + tilt = service.data.get(ATTR_TILT, None) + zoom = service.data.get(ATTR_ZOOM, None) + all_cameras = hass.data[ONVIF_DATA][ENTITIES] + entity_ids = extract_entity_ids(hass, service) + target_cameras = [] + if not entity_ids: + target_cameras = all_cameras + else: + target_cameras = [camera for camera in all_cameras + if camera.entity_id in entity_ids] + for camera in target_cameras: + camera.perform_ptz(pan, tilt, zoom) + + hass.services.async_register(DOMAIN, SERVICE_PTZ, handle_ptz, + schema=SERVICE_PTZ_SCHEMA) + add_devices([ONVIFHassCamera(hass, config)]) + + +class ONVIFHassCamera(Camera): """An implementation of an ONVIF camera.""" def __init__(self, hass, config): """Initialize a ONVIF camera.""" - from onvif import ONVIFService - import onvif 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 = '-q:v 2' - media = ONVIFService( - 'http://{}:{}/onvif/device_service'.format( - config.get(CONF_HOST), config.get(CONF_PORT)), - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - '{}/wsdl/media.wsdl'.format(os.path.dirname(onvif.__file__)) - ) - self._input = media.GetStreamUri().Uri - _LOGGER.debug("ONVIF Camera Using the following URL for %s: %s", - self._name, self._input) - - @asyncio.coroutine - def async_camera_image(self): + self._ffmpeg_arguments = config.get(CONF_EXTRA_ARGUMENTS) + self._profile_index = config.get(CONF_PROFILE) + self._input = None + self._media_service = \ + onvif.ONVIFService('http://{}:{}/onvif/device_service'.format( + self._host, self._port), + self._username, self._password, + '{}/wsdl/media.wsdl'.format(os.path.dirname( + onvif.__file__))) + + self._ptz_service = \ + onvif.ONVIFService('http://{}:{}/onvif/device_service'.format( + self._host, self._port), + self._username, self._password, + '{}/wsdl/ptz.wsdl'.format(os.path.dirname( + onvif.__file__))) + + def obtain_input_uri(self): + """Set the input uri for the camera.""" + from onvif import exceptions + _LOGGER.debug("Connecting with ONVIF Camera: %s on port %s", + self._host, self._port) + + try: + profiles = self._media_service.GetProfiles() + + if self._profile_index >= len(profiles): + _LOGGER.warning("ONVIF Camera '%s' doesn't provide profile %d." + " Using the last profile.", + self._name, self._profile_index) + self._profile_index = -1 + + req = self._media_service.create_type('GetStreamUri') + + # pylint: disable=protected-access + 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, uri_for_log) + # we won't need the media service anymore + self._media_service = None + except exceptions.ONVIFError as 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.""" + 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}}} + 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) + + 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) + + 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 ffmpeg.get_image( + image = await asyncio.shield(ffmpeg.get_image( self._input, output_format=IMAGE_JPEG, - extra_cmd=self._ffmpeg_arguments) + extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) return image - @asyncio.coroutine - 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 new file mode 100644 index 0000000000000..1984c21fadbb7 --- /dev/null +++ b/homeassistant/components/camera/proxy.py @@ -0,0 +1,244 @@ +""" +Proxy camera platform that enables image processing of camera data. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/proxy +""" +import logging +import asyncio +import aiohttp +import async_timeout + +import voluptuous as vol + +from homeassistant.util.async_ import run_coroutine_threadsafe +from homeassistant.helpers import config_validation as cv + +import homeassistant.util.dt as dt_util +from homeassistant.const import ( + CONF_NAME, CONF_ENTITY_ID, HTTP_HEADER_HA_AUTH) +from homeassistant.components.camera import ( + PLATFORM_SCHEMA, Camera) +from homeassistant.helpers.aiohttp_client import ( + async_get_clientsession, async_aiohttp_proxy_web) + +REQUIREMENTS = ['pillow==5.0.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_MAX_IMAGE_WIDTH = "max_image_width" +CONF_IMAGE_QUALITY = "image_quality" +CONF_IMAGE_REFRESH_RATE = "image_refresh_rate" +CONF_FORCE_RESIZE = "force_resize" +CONF_MAX_STREAM_WIDTH = "max_stream_width" +CONF_STREAM_QUALITY = "stream_quality" +CONF_CACHE_IMAGES = "cache_images" + +DEFAULT_BASENAME = "Camera Proxy" +DEFAULT_QUALITY = 75 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MAX_IMAGE_WIDTH): int, + vol.Optional(CONF_IMAGE_QUALITY): int, + vol.Optional(CONF_IMAGE_REFRESH_RATE): float, + vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean, + vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean, + vol.Optional(CONF_MAX_STREAM_WIDTH): int, + vol.Optional(CONF_STREAM_QUALITY): int, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Proxy camera platform.""" + async_add_devices([ProxyCamera(hass, config)]) + + +def _resize_image(image, opts): + """Resize image.""" + from PIL import Image + import io + + if not opts: + return image + + quality = opts.quality or DEFAULT_QUALITY + new_width = opts.max_width + + img = Image.open(io.BytesIO(image)) + imgfmt = str(img.format) + if imgfmt != 'PNG' and imgfmt != 'JPEG': + _LOGGER.debug("Image is of unsupported type: %s", imgfmt) + return image + + (old_width, old_height) = img.size + old_size = len(image) + if old_width <= new_width: + if opts.quality is None: + _LOGGER.debug("Image is smaller-than / equal-to requested width") + return image + new_width = old_width + + scale = new_width / float(old_width) + new_height = int((float(old_height)*float(scale))) + + img = img.resize((new_width, new_height), Image.ANTIALIAS) + imgbuf = io.BytesIO() + img.save(imgbuf, "JPEG", optimize=True, quality=quality) + newimage = imgbuf.getvalue() + if not opts.force_resize and len(newimage) >= old_size: + _LOGGER.debug("Using original image(%d bytes) " + "because resized image (%d bytes) is not smaller", + old_size, len(newimage)) + return image + + _LOGGER.debug("Resized image " + "from (%dx%d - %d bytes) " + "to (%dx%d - %d bytes)", + old_width, old_height, old_size, + new_width, new_height, len(newimage)) + return newimage + + +class ImageOpts(): + """The representation of image options.""" + + def __init__(self, max_width, quality, force_resize): + """Initialize image options.""" + self.max_width = max_width + self.quality = quality + self.force_resize = force_resize + + def __bool__(self): + """Bool evalution rules.""" + return bool(self.max_width or self.quality) + + +class ProxyCamera(Camera): + """The representation of a Proxy camera.""" + + def __init__(self, hass, config): + """Initialize a proxy camera component.""" + super().__init__() + self.hass = hass + self._proxied_camera = config.get(CONF_ENTITY_ID) + self._name = ( + config.get(CONF_NAME) or + "{} - {}".format(DEFAULT_BASENAME, self._proxied_camera)) + self._image_opts = ImageOpts( + config.get(CONF_MAX_IMAGE_WIDTH), + config.get(CONF_IMAGE_QUALITY), + config.get(CONF_FORCE_RESIZE)) + + self._stream_opts = ImageOpts( + config.get(CONF_MAX_STREAM_WIDTH), + config.get(CONF_STREAM_QUALITY), + True) + + self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE) + self._cache_images = bool( + config.get(CONF_IMAGE_REFRESH_RATE) + or config.get(CONF_CACHE_IMAGES)) + self._last_image_time = 0 + self._last_image = None + self._headers = ( + {HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password} + if self.hass.config.api.api_password is not None + else None) + + def camera_image(self): + """Return camera image.""" + return run_coroutine_threadsafe( + self.async_camera_image(), self.hass.loop).result() + + async def async_camera_image(self): + """Return a still image response from the camera.""" + now = dt_util.utcnow() + + if (self._image_refresh_rate and + now < self._last_image_time + self._image_refresh_rate): + return self._last_image + + self._last_image_time = now + url = "{}/api/camera_proxy/{}".format( + self.hass.config.api.base_url, self._proxied_camera) + try: + websession = async_get_clientsession(self.hass) + with async_timeout.timeout(10, loop=self.hass.loop): + response = await websession.get(url, headers=self._headers) + image = await response.read() + except asyncio.TimeoutError: + _LOGGER.error("Timeout getting camera image") + return self._last_image + except aiohttp.ClientError as err: + _LOGGER.error("Error getting new camera image: %s", err) + return self._last_image + + image = await self.hass.async_add_job( + _resize_image, image, self._image_opts) + + if self._cache_images: + self._last_image = image + return image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from camera images.""" + websession = async_get_clientsession(self.hass) + url = "{}/api/camera_proxy_stream/{}".format( + self.hass.config.api.base_url, self._proxied_camera) + stream_coro = websession.get(url, headers=self._headers) + + if not self._stream_opts: + await async_aiohttp_proxy_web(self.hass, request, stream_coro) + return + + response = aiohttp.web.StreamResponse() + response.content_type = ('multipart/x-mixed-replace; ' + 'boundary=--frameboundary') + await response.prepare(request) + + async def write(img_bytes): + """Write image to stream.""" + await response.write(bytes( + '--frameboundary\r\n' + 'Content-Type: {}\r\n' + 'Content-Length: {}\r\n\r\n'.format( + self.content_type, len(img_bytes)), + 'utf-8') + img_bytes + b'\r\n') + + with async_timeout.timeout(10, loop=self.hass.loop): + 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: + chunk = await stream.read(102400) + if not chunk: + break + data += chunk + jpg_start = data.find(b'\xff\xd8') + jpg_end = data.find(b'\xff\xd9') + if jpg_start != -1 and jpg_end != -1: + image = data[jpg_start:jpg_end + 2] + image = await self.hass.async_add_job( + _resize_image, image, self._stream_opts) + await write(image) + data = data[jpg_end + 2:] + except asyncio.CancelledError: + _LOGGER.debug("Stream closed by frontend.") + req.close() + response = None + + finally: + if response is not None: + await response.write_eof() + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/homeassistant/components/camera/ring.py b/homeassistant/components/camera/ring.py new file mode 100644 index 0000000000000..96956d24eec6a --- /dev/null +++ b/homeassistant/components/camera/ring.py @@ -0,0 +1,167 @@ +""" +This component provides support to the Ring Door Bell camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.ring/ +""" +import asyncio +import logging + +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.components.ring import ( + DATA_RING, CONF_ATTRIBUTION, NOTIFICATION_ID) +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import ATTR_ATTRIBUTION, CONF_SCAN_INTERVAL +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.util import dt as dt_util + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +DEPENDENCIES = ['ring', 'ffmpeg'] + +FORCE_REFRESH_INTERVAL = timedelta(minutes=45) + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_TITLE = 'Ring Camera Setup' + +SCAN_INTERVAL = timedelta(seconds=90) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up a Ring Door Bell and StickUp Camera.""" + ring = hass.data[DATA_RING] + + cams = [] + cams_no_plan = [] + for camera in ring.doorbells: + if camera.has_subscription: + cams.append(RingCam(hass, camera, config)) + else: + cams_no_plan.append(camera) + + for camera in ring.stickup_cams: + if camera.has_subscription: + cams.append(RingCam(hass, camera, config)) + else: + cams_no_plan.append(camera) + + # show notification for all cameras without an active subscription + if cams_no_plan: + cameras = str(', '.join([camera.name for camera in cams_no_plan])) + + err_msg = '''A Ring Protect Plan is required for the''' \ + ''' following cameras: {}.'''.format(cameras) + + _LOGGER.error(err_msg) + hass.components.persistent_notification.async_create( + 'Error: {}
    ' + 'You will need to restart hass after fixing.' + ''.format(err_msg), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + + async_add_devices(cams, True) + return True + + +class RingCam(Camera): + """An implementation of a Ring Door Bell camera.""" + + def __init__(self, hass, camera, device_info): + """Initialize a Ring Door Bell camera.""" + super(RingCam, self).__init__() + self._camera = camera + self._hass = hass + self._name = self._camera.name + self._ffmpeg = hass.data[DATA_FFMPEG] + self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) + self._last_video_id = self._camera.last_recording_id + self._video_url = self._camera.recording_url(self._last_video_id) + self._utcnow = dt_util.utcnow() + self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._camera.id, + 'firmware': self._camera.firmware, + 'kind': self._camera.kind, + 'timezone': self._camera.timezone, + 'type': self._camera.family, + 'video_url': self._video_url, + } + + @asyncio.coroutine + def async_camera_image(self): + """Return a still image response from the camera.""" + from haffmpeg import ImageFrame, IMAGE_JPEG + ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) + + if self._video_url is None: + return + + image = yield from asyncio.shield(ffmpeg.get_image( + self._video_url, output_format=IMAGE_JPEG, + extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) + return image + + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + + if self._video_url is None: + return + + stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) + yield from stream.open_camera( + self._video_url, extra_cmd=self._ffmpeg_arguments) + + yield from async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + yield from stream.close() + + @property + def should_poll(self): + """Update the image periodically.""" + return True + + def update(self): + """Update camera entity and refresh attributes.""" + _LOGGER.debug("Checking if Ring DoorBell needs to refresh video_url") + + self._camera.update() + self._utcnow = dt_util.utcnow() + + last_recording_id = self._camera.last_recording_id + + if self._last_video_id != last_recording_id or \ + self._utcnow >= self._expires_at: + + _LOGGER.info("Ring DoorBell properties refreshed") + + # update attributes if new video or if URL has expired + self._last_video_id = self._camera.last_recording_id + self._video_url = self._camera.recording_url(self._last_video_id) + self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow diff --git a/homeassistant/components/camera/rpi_camera.py b/homeassistant/components/camera/rpi_camera.py index 6e3d3622a3f57..91edf7d1053c1 100644 --- a/homeassistant/components/camera/rpi_camera.py +++ b/homeassistant/components/camera/rpi_camera.py @@ -8,6 +8,7 @@ import subprocess import logging import shutil +from tempfile import NamedTemporaryFile import voluptuous as vol @@ -28,7 +29,7 @@ DEFAULT_HORIZONTAL_FLIP = 0 DEFAULT_IMAGE_HEIGHT = 480 -DEFAULT_IMAGE_QUALITIY = 7 +DEFAULT_IMAGE_QUALITY = 7 DEFAULT_IMAGE_ROTATION = 0 DEFAULT_IMAGE_WIDTH = 640 DEFAULT_NAME = 'Raspberry Pi Camera' @@ -36,12 +37,12 @@ DEFAULT_VERTICAL_FLIP = 0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_FILE_PATH): cv.string, + vol.Optional(CONF_FILE_PATH): cv.isfile, vol.Optional(CONF_HORIZONTAL_FLIP, default=DEFAULT_HORIZONTAL_FLIP): vol.All(vol.Coerce(int), vol.Range(min=0, max=1)), vol.Optional(CONF_IMAGE_HEIGHT, default=DEFAULT_IMAGE_HEIGHT): vol.Coerce(int), - vol.Optional(CONF_IMAGE_QUALITY, default=DEFAULT_IMAGE_QUALITIY): + vol.Optional(CONF_IMAGE_QUALITY, default=DEFAULT_IMAGE_QUALITY): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), vol.Optional(CONF_IMAGE_ROTATION, default=DEFAULT_IMAGE_ROTATION): vol.All(vol.Coerce(int), vol.Range(min=0, max=359)), @@ -77,27 +78,36 @@ def setup_platform(hass, config, add_devices, discovery_info=None): CONF_TIMELAPSE: config.get(CONF_TIMELAPSE), CONF_HORIZONTAL_FLIP: config.get(CONF_HORIZONTAL_FLIP), CONF_VERTICAL_FLIP: config.get(CONF_VERTICAL_FLIP), - CONF_FILE_PATH: config.get(CONF_FILE_PATH, - os.path.join(os.path.dirname(__file__), - 'image.jpg')) + CONF_FILE_PATH: config.get(CONF_FILE_PATH) } ) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, kill_raspistill) - try: - # Try to create an empty file (or open existing) to ensure we have - # proper permissions. - open(setup_config[CONF_FILE_PATH], 'a').close() + file_path = setup_config[CONF_FILE_PATH] - add_devices([RaspberryCamera(setup_config)]) - except PermissionError: - _LOGGER.error("File path is not writable") - return False - except FileNotFoundError: - _LOGGER.error("Could not create output file (missing directory?)") + def delete_temp_file(*args): + """Delete the temporary file to prevent saving multiple temp images. + + Only used when no path is defined + """ + os.remove(file_path) + + # If no file path is defined, use a temporary file + if file_path is None: + temp_file = NamedTemporaryFile(suffix='.jpg', delete=False) + temp_file.close() + file_path = temp_file.name + setup_config[CONF_FILE_PATH] = file_path + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, delete_temp_file) + + # Check whether the file path has been whitelisted + elif not hass.config.is_allowed_path(file_path): + _LOGGER.error("'%s' is not a whitelisted directory", file_path) return False + add_devices([RaspberryCamera(setup_config)]) + class RaspberryCamera(Camera): """Representation of a Raspberry Pi camera.""" @@ -131,7 +141,7 @@ def __init__(self, device_info): stderr=subprocess.STDOUT) def camera_image(self): - """Return raspstill image response.""" + """Return raspistill image response.""" with open(self._config[CONF_FILE_PATH], 'rb') as file: return file.read() diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index b6ed22f708a0b..544fd0e6b8a4d 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -1,17 +1,51 @@ # Describes the format for available camera services enable_motion_detection: - description: Enable the motion detection in a camera - + description: Enable the motion detection in a camera. fields: entity_id: - description: Name(s) of entities to enable motion detection + description: Name(s) of entities to enable motion detection. example: 'camera.living_room_camera' disable_motion_detection: - description: Disable the motion detection in a camera - + description: Disable the motion detection in a camera. + fields: + entity_id: + description: Name(s) of entities to disable motion detection. + example: 'camera.living_room_camera' + +snapshot: + description: Take a snapshot from a camera. + fields: + entity_id: + description: Name(s) of entities to create snapshots from. + example: 'camera.living_room_camera' + filename: + description: Template of a Filename. Variable is entity_id. + example: '/tmp/snapshot_{{ entity_id }}' + +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: entity_id: - description: Name(s) of entities to disable motion detection + description: Name(s) of entities to pan, tilt or zoom. example: 'camera.living_room_camera' + pan: + description: "Direction of pan. Allowed values: LEFT, RIGHT." + example: 'LEFT' + tilt: + description: "Direction of tilt. Allowed values: DOWN, UP." + example: 'DOWN' + zoom: + description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT" + example: "ZOOM_IN" diff --git a/homeassistant/components/camera/skybell.py b/homeassistant/components/camera/skybell.py new file mode 100644 index 0000000000000..be3504dab78b3 --- /dev/null +++ b/homeassistant/components/camera/skybell.py @@ -0,0 +1,67 @@ +""" +Camera support for the Skybell HD Doorbell. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.skybell/ +""" +from datetime import timedelta +import logging + +import requests + +from homeassistant.components.camera import Camera +from homeassistant.components.skybell import ( + DOMAIN as SKYBELL_DOMAIN, SkybellDevice) + +DEPENDENCIES = ['skybell'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=90) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the platform for a Skybell device.""" + skybell = hass.data.get(SKYBELL_DOMAIN) + + sensors = [] + for device in skybell.get_devices(): + sensors.append(SkybellCamera(device)) + + add_devices(sensors, True) + + +class SkybellCamera(SkybellDevice, Camera): + """A camera implementation for Skybell devices.""" + + def __init__(self, device): + """Initialize a camera for a Skybell device.""" + SkybellDevice.__init__(self, device) + Camera.__init__(self) + self._name = self._device.name + self._url = None + self._response = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + def camera_image(self): + """Get the latest camera image.""" + super().update() + + if self._url != self._device.image: + self._url = self._device.image + + try: + self._response = requests.get( + self._url, stream=True, timeout=10) + except requests.HTTPError as err: + _LOGGER.warning("Failed to get camera image: %s", err) + self._response = None + + if not self._response: + return None + + return self._response.content diff --git a/homeassistant/components/camera/synology.py b/homeassistant/components/camera/synology.py index 90dfa58d8c54a..8bbb3e8c632ca 100644 --- a/homeassistant/components/camera/synology.py +++ b/homeassistant/components/camera/synology.py @@ -7,44 +7,25 @@ import asyncio import logging +import requests import voluptuous as vol -import aiohttp -import async_timeout - from homeassistant.const import ( CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_URL, CONF_WHITELIST, CONF_VERIFY_SSL, CONF_TIMEOUT) from homeassistant.components.camera import ( Camera, PLATFORM_SCHEMA) from homeassistant.helpers.aiohttp_client import ( - async_get_clientsession, async_create_clientsession, - async_aiohttp_proxy_web) + async_aiohttp_proxy_web, + async_get_clientsession) import homeassistant.helpers.config_validation as cv -from homeassistant.util.async import run_coroutine_threadsafe + +REQUIREMENTS = ['py-synology==0.2.0'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Synology Camera' -DEFAULT_STREAM_ID = '0' DEFAULT_TIMEOUT = 5 -CONF_CAMERA_NAME = 'camera_name' -CONF_STREAM_ID = 'stream_id' - -QUERY_CGI = 'query.cgi' -QUERY_API = 'SYNO.API.Info' -AUTH_API = 'SYNO.API.Auth' -CAMERA_API = 'SYNO.SurveillanceStation.Camera' -STREAMING_API = 'SYNO.SurveillanceStation.VideoStream' -SESSION_ID = '0' - -WEBAPI_PATH = '/webapi/' -AUTH_PATH = 'auth.cgi' -CAMERA_PATH = 'camera.cgi' -STREAMING_PATH = 'SurveillanceStation/videoStreaming.cgi' -CONTENT_TYPE_HEADER = 'Content-Type' - -SYNO_API_URL = '{0}{1}{2}' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -62,189 +43,90 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up a Synology IP Camera.""" verify_ssl = config.get(CONF_VERIFY_SSL) timeout = config.get(CONF_TIMEOUT) - websession_init = async_get_clientsession(hass, verify_ssl) - - # Determine API to use for authentication - syno_api_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, QUERY_CGI) - - query_payload = { - 'api': QUERY_API, - 'method': 'Query', - 'version': '1', - 'query': 'SYNO.' - } - try: - with async_timeout.timeout(timeout, loop=hass.loop): - query_req = yield from websession_init.get( - syno_api_url, - params=query_payload - ) - - # Skip content type check because Synology doesn't return JSON with - # right content type - query_resp = yield from query_req.json(content_type=None) - auth_path = query_resp['data'][AUTH_API]['path'] - camera_api = query_resp['data'][CAMERA_API]['path'] - camera_path = query_resp['data'][CAMERA_API]['path'] - streaming_path = query_resp['data'][STREAMING_API]['path'] - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", syno_api_url) - return False - # Authticate to NAS to get a session id - syno_auth_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, auth_path) - - session_id = yield from get_session_id( - hass, - websession_init, - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - syno_auth_url, - timeout - ) - - # init websession - websession = async_create_clientsession( - hass, verify_ssl, cookies={'id': session_id}) - - # Use SessionID to get cameras in system - syno_camera_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, camera_api) - - camera_payload = { - 'api': CAMERA_API, - 'method': 'List', - 'version': '1' - } try: - with async_timeout.timeout(timeout, loop=hass.loop): - camera_req = yield from websession.get( - syno_camera_url, - params=camera_payload - ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", syno_camera_url) + from synology.surveillance_station import SurveillanceStation + surveillance = SurveillanceStation( + config.get(CONF_URL), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + verify_ssl=verify_ssl, + timeout=timeout + ) + except (requests.exceptions.RequestException, ValueError): + _LOGGER.exception("Error when initializing SurveillanceStation") return False - camera_resp = yield from camera_req.json(content_type=None) - cameras = camera_resp['data']['cameras'] + cameras = surveillance.get_all_cameras() # add cameras devices = [] for camera in cameras: if not config.get(CONF_WHITELIST): - camera_id = camera['id'] - snapshot_path = camera['snapshot_path'] - - device = SynologyCamera( - hass, websession, config, camera_id, camera['name'], - snapshot_path, streaming_path, camera_path, auth_path, timeout - ) + device = SynologyCamera(surveillance, camera.camera_id, verify_ssl) devices.append(device) async_add_devices(devices) -@asyncio.coroutine -def get_session_id(hass, websession, username, password, login_url, timeout): - """Get a session id.""" - auth_payload = { - 'api': AUTH_API, - 'method': 'Login', - 'version': '2', - 'account': username, - 'passwd': password, - 'session': 'SurveillanceStation', - 'format': 'sid' - } - try: - with async_timeout.timeout(timeout, loop=hass.loop): - auth_req = yield from websession.get( - login_url, - params=auth_payload - ) - auth_resp = yield from auth_req.json(content_type=None) - return auth_resp['data']['sid'] - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", login_url) - return False - - class SynologyCamera(Camera): """An implementation of a Synology NAS based IP camera.""" - def __init__(self, hass, websession, config, camera_id, - camera_name, snapshot_path, streaming_path, camera_path, - auth_path, timeout): + def __init__(self, surveillance, camera_id, verify_ssl): """Initialize a Synology Surveillance Station camera.""" super().__init__() - self.hass = hass - self._websession = websession - self._name = camera_name - self._synology_url = config.get(CONF_URL) - self._camera_name = config.get(CONF_CAMERA_NAME) - self._stream_id = config.get(CONF_STREAM_ID) + self._surveillance = surveillance self._camera_id = camera_id - self._snapshot_path = snapshot_path - self._streaming_path = streaming_path - self._camera_path = camera_path - self._auth_path = auth_path - self._timeout = timeout + self._verify_ssl = verify_ssl + self._camera = self._surveillance.get_camera(camera_id) + self._motion_setting = self._surveillance.get_motion_setting(camera_id) + self.is_streaming = self._camera.is_enabled def camera_image(self): """Return bytes of camera image.""" - return run_coroutine_threadsafe( - self.async_camera_image(), self.hass.loop).result() - - @asyncio.coroutine - def async_camera_image(self): - """Return a still image response from the camera.""" - image_url = SYNO_API_URL.format( - self._synology_url, WEBAPI_PATH, self._camera_path) - - image_payload = { - 'api': CAMERA_API, - 'method': 'GetSnapshot', - 'version': '1', - 'cameraId': self._camera_id - } - try: - with async_timeout.timeout(self._timeout, loop=self.hass.loop): - response = yield from self._websession.get( - image_url, - params=image_payload - ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Error fetching %s", image_url) - return None - - image = yield from response.read() - - return image + return self._surveillance.get_camera_image(self._camera_id) @asyncio.coroutine def handle_async_mjpeg_stream(self, request): """Return a MJPEG stream image response directly from the camera.""" - streaming_url = SYNO_API_URL.format( - self._synology_url, WEBAPI_PATH, self._streaming_path) - - streaming_payload = { - 'api': STREAMING_API, - 'method': 'Stream', - 'version': '1', - 'cameraId': self._camera_id, - 'format': 'mjpeg' - } - stream_coro = self._websession.get( - streaming_url, params=streaming_payload) + streaming_url = self._camera.video_stream_url + + websession = async_get_clientsession(self.hass, self._verify_ssl) + stream_coro = websession.get(streaming_url) yield from async_aiohttp_proxy_web(self.hass, request, stream_coro) @property def name(self): """Return the name of this device.""" - return self._name + return self._camera.name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._camera.is_recording + + def should_poll(self): + """Update the recording state periodically.""" + return True + + def update(self): + """Update the status of the camera.""" + self._surveillance.update() + self._camera = self._surveillance.get_camera(self._camera.camera_id) + self._motion_setting = self._surveillance.get_motion_setting( + self._camera.camera_id) + self.is_streaming = self._camera.is_enabled + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return self._motion_setting.is_enabled + + def enable_motion_detection(self): + """Enable motion detection in the camera.""" + self._surveillance.enable_motion_detection(self._camera_id) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + self._surveillance.disable_motion_detection(self._camera_id) diff --git a/homeassistant/components/camera/usps.py b/homeassistant/components/camera/usps.py index 545ea9798de4a..6c76d0d66d850 100644 --- a/homeassistant/components/camera/usps.py +++ b/homeassistant/components/camera/usps.py @@ -77,7 +77,7 @@ def name(self): def model(self): """Return date of mail as model.""" try: - return 'Date: {}'.format(self._usps.mail[0]['date']) + return 'Date: {}'.format(str(self._usps.mail[0]['date'])) except IndexError: return None diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index 3203a10b39125..20dceb8a1c5da 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -14,7 +14,7 @@ from homeassistant.components.camera import Camera, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['uvcclient==0.10.0'] +REQUIREMENTS = ['uvcclient==0.10.1'] _LOGGER = logging.getLogger(__name__) @@ -82,6 +82,7 @@ def __init__(self, nvr, uuid, name, password): self.is_streaming = False self._connect_addr = None self._camera = None + self._motion_status = False @property def name(self): @@ -94,6 +95,12 @@ def is_recording(self): caminfo = self._nvr.get_camera(self._uuid) return caminfo['recordingSettings']['fullTimeRecordEnabled'] + @property + def motion_detection_enabled(self): + """Camera Motion Detection Status.""" + caminfo = self._nvr.get_camera(self._uuid) + return caminfo['recordingSettings']['motionRecordEnabled'] + @property def brand(self): """Return the brand of this camera.""" @@ -120,6 +127,9 @@ def _login(self): else: client_cls = uvc_camera.UVCCameraClient + if caminfo['username'] is None: + caminfo['username'] = 'ubnt' + camera = None for addr in addrs: try: @@ -165,3 +175,26 @@ def _get_image(retry=True): raise return _get_image() + + def set_motion_detection(self, mode): + """Set motion detection on or off.""" + from uvcclient.nvr import NvrError + if mode is True: + set_mode = 'motion' + else: + set_mode = 'none' + + try: + self._nvr.set_recordmode(self._uuid, set_mode) + self._motion_status = mode + except NvrError as err: + _LOGGER.error("Unable to set recordmode to %s", set_mode) + _LOGGER.debug(err) + + def enable_motion_detection(self): + """Enable motion detection in camera.""" + self.set_motion_detection(True) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + self.set_motion_detection(False) diff --git a/homeassistant/components/camera/xeoma.py b/homeassistant/components/camera/xeoma.py new file mode 100644 index 0000000000000..cec04b52047b8 --- /dev/null +++ b/homeassistant/components/camera/xeoma.py @@ -0,0 +1,120 @@ +""" +Support for Xeoma Cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.xeoma/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) +from homeassistant.helpers import config_validation as cv + +REQUIREMENTS = ['pyxeoma==1.4.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_CAMERAS = 'cameras' +CONF_HIDE = 'hide' +CONF_IMAGE_NAME = 'image_name' +CONF_NEW_VERSION = 'new_version' +CONF_VIEWER_PASSWORD = 'viewer_password' +CONF_VIEWER_USERNAME = 'viewer_username' + +CAMERAS_SCHEMA = vol.Schema({ + vol.Required(CONF_IMAGE_NAME): cv.string, + vol.Optional(CONF_HIDE, default=False): cv.boolean, + vol.Optional(CONF_NAME): cv.string, +}, required=False) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_CAMERAS): + vol.Schema(vol.All(cv.ensure_list, [CAMERAS_SCHEMA])), + vol.Optional(CONF_NEW_VERSION, default=True): cv.boolean, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Discover and setup Xeoma Cameras.""" + from pyxeoma.xeoma import Xeoma, XeomaError + + host = config[CONF_HOST] + login = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + xeoma = Xeoma(host, login, password) + + try: + await xeoma.async_test_connection() + discovered_image_names = await xeoma.async_get_image_names() + discovered_cameras = [ + { + CONF_IMAGE_NAME: image_name, + CONF_HIDE: False, + CONF_NAME: image_name, + CONF_VIEWER_USERNAME: username, + CONF_VIEWER_PASSWORD: pw + + } + for image_name, username, pw in discovered_image_names + ] + + for cam in config.get(CONF_CAMERAS, []): + # https://github.com/PyCQA/pylint/issues/1830 + # pylint: disable=stop-iteration-return + camera = next( + (dc for dc in discovered_cameras + if dc[CONF_IMAGE_NAME] == cam[CONF_IMAGE_NAME]), None) + + if camera is not None: + if CONF_NAME in cam: + camera[CONF_NAME] = cam[CONF_NAME] + if CONF_HIDE in cam: + camera[CONF_HIDE] = cam[CONF_HIDE] + + cameras = list(filter(lambda c: not c[CONF_HIDE], discovered_cameras)) + async_add_devices( + [XeomaCamera(xeoma, camera[CONF_IMAGE_NAME], camera[CONF_NAME], + camera[CONF_VIEWER_USERNAME], + camera[CONF_VIEWER_PASSWORD]) for camera in cameras]) + except XeomaError as err: + _LOGGER.error("Error: %s", err.message) + return + + +class XeomaCamera(Camera): + """Implementation of a Xeoma camera.""" + + def __init__(self, xeoma, image, name, username, password): + """Initialize a Xeoma camera.""" + super().__init__() + self._xeoma = xeoma + self._name = name + self._image = image + self._username = username + self._password = password + self._last_image = None + + async def async_camera_image(self): + """Return a still image response from the camera.""" + from pyxeoma.xeoma import XeomaError + try: + image = await self._xeoma.async_get_camera_image( + self._image, self._username, self._password) + self._last_image = image + except XeomaError as err: + _LOGGER.error("Error fetching image: %s", err.message) + + return self._last_image + + @property + def name(self): + """Return the name of this device.""" + return self._name diff --git a/homeassistant/components/camera/yi.py b/homeassistant/components/camera/yi.py new file mode 100644 index 0000000000000..41fe816c4799c --- /dev/null +++ b/homeassistant/components/camera/yi.py @@ -0,0 +1,137 @@ +""" +This component provides support for Xiaomi Cameras (HiSilicon Hi3518e V200). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.yi/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PATH, + CONF_PASSWORD, CONF_PORT, CONF_USERNAME) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream + +DEPENDENCIES = ['ffmpeg'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_BRAND = 'YI Home Camera' +DEFAULT_PASSWORD = '' +DEFAULT_PATH = '/tmp/sd/record' +DEFAULT_PORT = 21 +DEFAULT_USERNAME = 'root' + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, + vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string +}) + + +async def async_setup_platform(hass, + config, + async_add_devices, + discovery_info=None): + """Set up a Yi Camera.""" + _LOGGER.debug('Received configuration: %s', config) + async_add_devices([YiCamera(hass, config)], True) + + +class YiCamera(Camera): + """Define an implementation of a Yi Camera.""" + + def __init__(self, hass, config): + """Initialize.""" + super().__init__() + self._extra_arguments = config.get(CONF_FFMPEG_ARGUMENTS) + self._last_image = None + self._last_url = None + self._manager = hass.data[DATA_FFMPEG] + self._name = config.get(CONF_NAME) + self.host = config.get(CONF_HOST) + self.port = config.get(CONF_PORT) + self.path = config.get(CONF_PATH) + self.user = config.get(CONF_USERNAME) + self.passwd = config.get(CONF_PASSWORD) + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def brand(self): + """Camera brand.""" + return DEFAULT_BRAND + + def get_latest_video_url(self): + """Retrieve the latest video file from the customized Yi FTP server.""" + from ftplib import FTP, error_perm + + ftp = FTP(self.host) + try: + ftp.login(self.user, self.passwd) + except error_perm as exc: + _LOGGER.error('There was an error while logging into the camera') + _LOGGER.debug(exc) + return False + + try: + ftp.cwd(self.path) + except error_perm as exc: + _LOGGER.error('Unable to find path: %s', self.path) + _LOGGER.debug(exc) + return False + + dirs = [d for d in ftp.nlst() if '.' not in d] + if not dirs: + _LOGGER.warning("There don't appear to be any uploaded videos") + return False + + latest_dir = dirs[-1] + ftp.cwd(latest_dir) + videos = ftp.nlst() + if not videos: + _LOGGER.info('Video folder "%s" is empty; delaying', latest_dir) + return False + + return 'ftp://{0}:{1}@{2}:{3}{4}/{5}/{6}'.format( + self.user, self.passwd, self.host, self.port, self.path, + latest_dir, videos[-1]) + + async def async_camera_image(self): + """Return a still image response from the camera.""" + from haffmpeg import ImageFrame, IMAGE_JPEG + + url = await self.hass.async_add_job(self.get_latest_video_url) + if url != self._last_url: + ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) + self._last_image = await asyncio.shield(ffmpeg.get_image( + url, output_format=IMAGE_JPEG, + extra_cmd=self._extra_arguments), loop=self.hass.loop) + self._last_url = url + + return self._last_image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + + stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) + await stream.open_camera( + self._last_url, extra_cmd=self._extra_arguments) + + await async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + await stream.close() diff --git a/homeassistant/components/canary.py b/homeassistant/components/canary.py new file mode 100644 index 0000000000000..4d0fbe617b2c0 --- /dev/null +++ b/homeassistant/components/canary.py @@ -0,0 +1,128 @@ +""" +Support for Canary. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/canary/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +from requests import ConnectTimeout, HTTPError + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +REQUIREMENTS = ['py-canary==0.5.0'] + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_ID = 'canary_notification' +NOTIFICATION_TITLE = 'Canary Setup' + +DOMAIN = 'canary' +DATA_CANARY = 'canary' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) +DEFAULT_TIMEOUT = 10 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + +CANARY_COMPONENTS = [ + 'alarm_control_panel', 'camera', 'sensor' +] + + +def setup(hass, config): + """Set up the Canary component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + timeout = conf.get(CONF_TIMEOUT) + + try: + hass.data[DATA_CANARY] = CanaryData(username, password, timeout) + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Canary 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 + + for component in CANARY_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class CanaryData(object): + """Get the latest data and update the states.""" + + def __init__(self, username, password, timeout): + """Init the Canary data object.""" + from canary.api import Api + self._api = Api(username, password, timeout) + + self._locations_by_id = {} + self._readings_by_device_id = {} + self._entries_by_location_id = {} + + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Get the latest data from py-canary.""" + for location in self._api.get_locations(): + location_id = location.location_id + + self._locations_by_id[location_id] = location + self._entries_by_location_id[location_id] = self._api.get_entries( + location_id, entry_type="motion", limit=1) + + for device in location.devices: + if device.is_online: + self._readings_by_device_id[device.device_id] = \ + self._api.get_latest_readings(device.device_id) + + @property + def locations(self): + """Return a list of locations.""" + return self._locations_by_id.values() + + def get_motion_entries(self, location_id): + """Return a list of motion entries based on location_id.""" + return self._entries_by_location_id.get(location_id, []) + + def get_location(self, location_id): + """Return a location based on location_id.""" + return self._locations_by_id.get(location_id, []) + + def get_readings(self, device_id): + """Return a list of readings based on device_id.""" + return self._readings_by_device_id.get(device_id, []) + + def get_reading(self, device_id, sensor_type): + """Return reading for device_id and sensor type.""" + readings = self._readings_by_device_id.get(device_id, []) + return next(( + reading.value for reading in readings + if reading.sensor_type == sensor_type), None) + + def set_location_mode(self, location_id, mode_name, is_private=False): + """Set location mode.""" + self._api.set_location_mode(location_id, mode_name, is_private) + self.update(no_throttle=True) + + def get_live_stream_session(self, device): + """Return live stream session.""" + return self._api.get_live_stream_session(device) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 1f91930125494..550d4035ddd14 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -7,23 +7,21 @@ import asyncio from datetime import timedelta import logging -import os import functools as ft -from numbers import Number import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass +from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.util.temperature import convert as convert_temperature from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN, - TEMP_CELSIUS) - + ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_ON, SERVICE_TURN_OFF, + STATE_ON, STATE_OFF, STATE_UNKNOWN, TEMP_CELSIUS, PRECISION_WHOLE, + PRECISION_TENTHS, ) DOMAIN = 'climate' ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -42,8 +40,29 @@ STATE_COOL = 'cool' STATE_IDLE = 'idle' STATE_AUTO = 'auto' +STATE_MANUAL = 'manual' STATE_DRY = 'dry' STATE_FAN_ONLY = 'fan_only' +STATE_ECO = 'eco' +STATE_ELECTRIC = 'electric' +STATE_PERFORMANCE = 'performance' +STATE_HIGH_DEMAND = 'high_demand' +STATE_HEAT_PUMP = 'heat_pump' +STATE_GAS = 'gas' + +SUPPORT_TARGET_TEMPERATURE = 1 +SUPPORT_TARGET_TEMPERATURE_HIGH = 2 +SUPPORT_TARGET_TEMPERATURE_LOW = 4 +SUPPORT_TARGET_HUMIDITY = 8 +SUPPORT_TARGET_HUMIDITY_HIGH = 16 +SUPPORT_TARGET_HUMIDITY_LOW = 32 +SUPPORT_FAN_MODE = 64 +SUPPORT_OPERATION_MODE = 128 +SUPPORT_HOLD_MODE = 256 +SUPPORT_SWING_MODE = 512 +SUPPORT_AWAY_MODE = 1024 +SUPPORT_AUX_HEAT = 2048 +SUPPORT_ON_OFF = 4096 ATTR_CURRENT_TEMPERATURE = 'current_temperature' ATTR_MAX_TEMP = 'max_temp' @@ -65,11 +84,6 @@ ATTR_SWING_MODE = 'swing_mode' ATTR_SWING_LIST = 'swing_list' -# The degree of precision for each platform -PRECISION_WHOLE = 1 -PRECISION_HALVES = 0.5 -PRECISION_TENTHS = 0.1 - CONVERTIBLE_ATTRIBUTE = [ ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, @@ -78,6 +92,10 @@ _LOGGER = logging.getLogger(__name__) +ON_OFF_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + SET_AWAY_MODE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_AWAY_MODE): cv.boolean, @@ -147,7 +165,7 @@ def set_hold_mode(hass, hold_mode, entity_id=None): @bind_hass def set_aux_heat(hass, aux_heat, entity_id=None): - """Turn all or specified climate devices auxillary heater on.""" + """Turn all or specified climate devices auxiliary heater on.""" data = { ATTR_AUX_HEAT: aux_heat } @@ -220,96 +238,85 @@ def set_swing_mode(hass, swing_mode, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_SWING_MODE, data) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up climate devices.""" component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) - yield from component.async_setup(config) - - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - - @asyncio.coroutine - def _async_update_climate(target_climate): - """Update climate entity after service stuff.""" - update_tasks = [] - for climate in target_climate: - if not climate.should_poll: - continue + await component.async_setup(config) - update_coro = hass.async_add_job( - climate.async_update_ha_state(True)) - if hasattr(climate, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro - - if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) - - @asyncio.coroutine - def async_away_mode_set_service(service): + async def async_away_mode_set_service(service): """Set away mode on target climate devices.""" target_climate = component.async_extract_from_service(service) away_mode = service.data.get(ATTR_AWAY_MODE) + update_tasks = [] for climate in target_climate: if away_mode: - yield from climate.async_turn_away_mode_on() + await climate.async_turn_away_mode_on() else: - yield from climate.async_turn_away_mode_off() + await climate.async_turn_away_mode_off() - yield from _async_update_climate(target_climate) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_AWAY_MODE, async_away_mode_set_service, - descriptions.get(SERVICE_SET_AWAY_MODE), schema=SET_AWAY_MODE_SCHEMA) - @asyncio.coroutine - def async_hold_mode_set_service(service): + async def async_hold_mode_set_service(service): """Set hold mode on target climate devices.""" target_climate = component.async_extract_from_service(service) hold_mode = service.data.get(ATTR_HOLD_MODE) + update_tasks = [] for climate in target_climate: - yield from climate.async_set_hold_mode(hold_mode) + await climate.async_set_hold_mode(hold_mode) - yield from _async_update_climate(target_climate) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_HOLD_MODE, async_hold_mode_set_service, - descriptions.get(SERVICE_SET_HOLD_MODE), schema=SET_HOLD_MODE_SCHEMA) - @asyncio.coroutine - def async_aux_heat_set_service(service): + async def async_aux_heat_set_service(service): """Set auxiliary heater on target climate devices.""" target_climate = component.async_extract_from_service(service) aux_heat = service.data.get(ATTR_AUX_HEAT) + update_tasks = [] for climate in target_climate: if aux_heat: - yield from climate.async_turn_aux_heat_on() + await climate.async_turn_aux_heat_on() else: - yield from climate.async_turn_aux_heat_off() + await climate.async_turn_aux_heat_off() - yield from _async_update_climate(target_climate) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_AUX_HEAT, async_aux_heat_set_service, - descriptions.get(SERVICE_SET_AUX_HEAT), schema=SET_AUX_HEAT_SCHEMA) - @asyncio.coroutine - def async_temperature_set_service(service): + async def async_temperature_set_service(service): """Set temperature on the target climate devices.""" target_climate = component.async_extract_from_service(service) + update_tasks = [] for climate in target_climate: kwargs = {} for value, temp in service.data.items(): @@ -322,83 +329,124 @@ def async_temperature_set_service(service): else: kwargs[value] = temp - yield from climate.async_set_temperature(**kwargs) + await climate.async_set_temperature(**kwargs) - yield from _async_update_climate(target_climate) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_TEMPERATURE, async_temperature_set_service, - descriptions.get(SERVICE_SET_TEMPERATURE), schema=SET_TEMPERATURE_SCHEMA) - @asyncio.coroutine - def async_humidity_set_service(service): + async def async_humidity_set_service(service): """Set humidity on the target climate devices.""" target_climate = component.async_extract_from_service(service) humidity = service.data.get(ATTR_HUMIDITY) + update_tasks = [] for climate in target_climate: - yield from climate.async_set_humidity(humidity) + await climate.async_set_humidity(humidity) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) - yield from _async_update_climate(target_climate) + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_HUMIDITY, async_humidity_set_service, - descriptions.get(SERVICE_SET_HUMIDITY), schema=SET_HUMIDITY_SCHEMA) - @asyncio.coroutine - def async_fan_mode_set_service(service): + async def async_fan_mode_set_service(service): """Set fan mode on target climate devices.""" target_climate = component.async_extract_from_service(service) fan = service.data.get(ATTR_FAN_MODE) + update_tasks = [] for climate in target_climate: - yield from climate.async_set_fan_mode(fan) + await climate.async_set_fan_mode(fan) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) - yield from _async_update_climate(target_climate) + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_FAN_MODE, async_fan_mode_set_service, - descriptions.get(SERVICE_SET_FAN_MODE), schema=SET_FAN_MODE_SCHEMA) - @asyncio.coroutine - def async_operation_set_service(service): + async def async_operation_set_service(service): """Set operating mode on the target climate devices.""" target_climate = component.async_extract_from_service(service) operation_mode = service.data.get(ATTR_OPERATION_MODE) + update_tasks = [] for climate in target_climate: - yield from climate.async_set_operation_mode(operation_mode) + await climate.async_set_operation_mode(operation_mode) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) - yield from _async_update_climate(target_climate) + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_OPERATION_MODE, async_operation_set_service, - descriptions.get(SERVICE_SET_OPERATION_MODE), schema=SET_OPERATION_MODE_SCHEMA) - @asyncio.coroutine - def async_swing_set_service(service): + async def async_swing_set_service(service): """Set swing mode on the target climate devices.""" target_climate = component.async_extract_from_service(service) swing_mode = service.data.get(ATTR_SWING_MODE) + update_tasks = [] for climate in target_climate: - yield from climate.async_set_swing_mode(swing_mode) + await climate.async_set_swing_mode(swing_mode) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) - yield from _async_update_climate(target_climate) + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_SWING_MODE, async_swing_set_service, - descriptions.get(SERVICE_SET_SWING_MODE), schema=SET_SWING_MODE_SCHEMA) + async def async_on_off_service(service): + """Handle on/off calls.""" + target_climate = component.async_extract_from_service(service) + + update_tasks = [] + for climate in target_climate: + if service.service == SERVICE_TURN_ON: + await climate.async_turn_on() + elif service.service == SERVICE_TURN_OFF: + await climate.async_turn_off() + + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) + + hass.services.async_register( + DOMAIN, SERVICE_TURN_OFF, async_on_off_service, + schema=ON_OFF_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_TURN_ON, async_on_off_service, + schema=ON_OFF_SERVICE_SCHEMA) + return True @@ -409,8 +457,12 @@ class ClimateDevice(Entity): @property def state(self): """Return the current state.""" + if self.is_on is False: + return STATE_OFF if self.current_operation: return self.current_operation + if self.is_on: + return STATE_ON return STATE_UNKNOWN @property @@ -424,59 +476,68 @@ def precision(self): def state_attributes(self): """Return the optional state attributes.""" data = { - ATTR_CURRENT_TEMPERATURE: - self._convert_for_display(self.current_temperature), - ATTR_MIN_TEMP: self._convert_for_display(self.min_temp), - ATTR_MAX_TEMP: self._convert_for_display(self.max_temp), - ATTR_TEMPERATURE: - self._convert_for_display(self.target_temperature), + ATTR_CURRENT_TEMPERATURE: show_temp( + self.hass, self.current_temperature, self.temperature_unit, + self.precision), + ATTR_MIN_TEMP: show_temp( + self.hass, self.min_temp, self.temperature_unit, + self.precision), + ATTR_MAX_TEMP: show_temp( + self.hass, self.max_temp, self.temperature_unit, + self.precision), + ATTR_TEMPERATURE: show_temp( + self.hass, self.target_temperature, self.temperature_unit, + self.precision), } + supported_features = self.supported_features if self.target_temperature_step is not None: data[ATTR_TARGET_TEMP_STEP] = self.target_temperature_step - target_temp_high = self.target_temperature_high - if target_temp_high is not None: - data[ATTR_TARGET_TEMP_HIGH] = self._convert_for_display( - self.target_temperature_high) - data[ATTR_TARGET_TEMP_LOW] = self._convert_for_display( - self.target_temperature_low) + if supported_features & SUPPORT_TARGET_TEMPERATURE_HIGH: + data[ATTR_TARGET_TEMP_HIGH] = show_temp( + self.hass, self.target_temperature_high, self.temperature_unit, + self.precision) + + if supported_features & SUPPORT_TARGET_TEMPERATURE_LOW: + data[ATTR_TARGET_TEMP_LOW] = show_temp( + self.hass, self.target_temperature_low, self.temperature_unit, + self.precision) - humidity = self.target_humidity - if humidity is not None: - data[ATTR_HUMIDITY] = humidity + if supported_features & SUPPORT_TARGET_HUMIDITY: + data[ATTR_HUMIDITY] = self.target_humidity data[ATTR_CURRENT_HUMIDITY] = self.current_humidity - data[ATTR_MIN_HUMIDITY] = self.min_humidity - data[ATTR_MAX_HUMIDITY] = self.max_humidity - fan_mode = self.current_fan_mode - if fan_mode is not None: - data[ATTR_FAN_MODE] = fan_mode + if supported_features & SUPPORT_TARGET_HUMIDITY_LOW: + data[ATTR_MIN_HUMIDITY] = self.min_humidity + + if supported_features & SUPPORT_TARGET_HUMIDITY_HIGH: + data[ATTR_MAX_HUMIDITY] = self.max_humidity + + if supported_features & SUPPORT_FAN_MODE: + data[ATTR_FAN_MODE] = self.current_fan_mode if self.fan_list: data[ATTR_FAN_LIST] = self.fan_list - operation_mode = self.current_operation - if operation_mode is not None: - data[ATTR_OPERATION_MODE] = operation_mode + if supported_features & SUPPORT_OPERATION_MODE: + data[ATTR_OPERATION_MODE] = self.current_operation if self.operation_list: data[ATTR_OPERATION_LIST] = self.operation_list - is_hold = self.current_hold_mode - if is_hold is not None: - data[ATTR_HOLD_MODE] = is_hold + if supported_features & SUPPORT_HOLD_MODE: + data[ATTR_HOLD_MODE] = self.current_hold_mode - swing_mode = self.current_swing_mode - if swing_mode is not None: - data[ATTR_SWING_MODE] = swing_mode + if supported_features & SUPPORT_SWING_MODE: + data[ATTR_SWING_MODE] = self.current_swing_mode if self.swing_list: data[ATTR_SWING_LIST] = self.swing_list - is_away = self.is_away_mode_on - if is_away is not None: + if supported_features & SUPPORT_AWAY_MODE: + is_away = self.is_away_mode_on data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF - is_aux_heat = self.is_aux_heat_on - if is_aux_heat is not None: + if supported_features & SUPPORT_AUX_HEAT: + is_aux_heat = self.is_aux_heat_on data[ATTR_AUX_HEAT] = STATE_ON if is_aux_heat else STATE_OFF return data @@ -546,6 +607,11 @@ def current_hold_mode(self): """Return the current hold mode, e.g., home, away, temp.""" return None + @property + def is_on(self): + """Return true if on.""" + return None + @property def is_aux_heat_on(self): """Return true if aux heater.""" @@ -594,16 +660,16 @@ def async_set_humidity(self, humidity): """ return self.hass.async_add_job(self.set_humidity, humidity) - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target fan mode.""" raise NotImplementedError() - def async_set_fan_mode(self, fan): + def async_set_fan_mode(self, fan_mode): """Set new target fan mode. This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(self.set_fan_mode, fan) + return self.hass.async_add_job(self.set_fan_mode, fan_mode) def set_operation_mode(self, operation_mode): """Set new target operation mode.""" @@ -661,27 +727,54 @@ def async_set_hold_mode(self, hold_mode): return self.hass.async_add_job(self.set_hold_mode, hold_mode) def turn_aux_heat_on(self): - """Turn auxillary heater on.""" + """Turn auxiliary heater on.""" raise NotImplementedError() def async_turn_aux_heat_on(self): - """Turn auxillary heater on. + """Turn auxiliary heater on. This method must be run in the event loop and returns a coroutine. """ return self.hass.async_add_job(self.turn_aux_heat_on) def turn_aux_heat_off(self): - """Turn auxillary heater off.""" + """Turn auxiliary heater off.""" raise NotImplementedError() def async_turn_aux_heat_off(self): - """Turn auxillary heater off. + """Turn auxiliary heater off. This method must be run in the event loop and returns a coroutine. """ return self.hass.async_add_job(self.turn_aux_heat_off) + def turn_on(self): + """Turn device on.""" + raise NotImplementedError() + + def async_turn_on(self): + """Turn device on. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.turn_on) + + def turn_off(self): + """Turn device off.""" + raise NotImplementedError() + + def async_turn_off(self): + """Turn device off. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.turn_off) + + @property + def supported_features(self): + """Return the list of supported features.""" + raise NotImplementedError() + @property def min_temp(self): """Return the minimum temperature.""" @@ -701,24 +794,3 @@ def min_humidity(self): def max_humidity(self): """Return the maximum humidity.""" return 99 - - def _convert_for_display(self, temp): - """Convert temperature into preferred units for display purposes.""" - if temp is None: - return temp - - # if the temperature is not a number this can cause issues - # with polymer components, so bail early there. - if not isinstance(temp, Number): - raise TypeError("Temperature is not a number: %s" % temp) - - if self.temperature_unit != self.unit_of_measurement: - temp = convert_temperature( - temp, self.temperature_unit, self.unit_of_measurement) - # Round in the units appropriate - if self.precision == PRECISION_HALVES: - return round(temp * 2) / 2.0 - elif self.precision == PRECISION_TENTHS: - return round(temp, 1) - # PRECISION_WHOLE as a fall back - return round(temp) diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py new file mode 100644 index 0000000000000..2c49b25a39d9a --- /dev/null +++ b/homeassistant/components/climate/daikin.py @@ -0,0 +1,265 @@ +""" +Support for the Daikin HVAC. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.daikin/ +""" +import logging +import re + +import voluptuous as vol + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, ATTR_OPERATION_MODE, + ATTR_SWING_MODE, PLATFORM_SCHEMA, STATE_AUTO, STATE_COOL, STATE_DRY, + STATE_FAN_ONLY, STATE_HEAT, STATE_OFF, SUPPORT_FAN_MODE, + SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE, + ClimateDevice) +from homeassistant.components.daikin import ( + ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, ATTR_TARGET_TEMPERATURE, + daikin_api_setup) +from homeassistant.const import ( + ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pydaikin==0.4'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, +}) + +HA_STATE_TO_DAIKIN = { + STATE_FAN_ONLY: 'fan', + STATE_DRY: 'dry', + STATE_COOL: 'cool', + STATE_HEAT: 'hot', + STATE_AUTO: 'auto', + STATE_OFF: 'off', +} + +HA_ATTR_TO_DAIKIN = { + ATTR_OPERATION_MODE: 'mode', + ATTR_FAN_MODE: 'f_rate', + ATTR_SWING_MODE: 'f_dir', + ATTR_INSIDE_TEMPERATURE: 'htemp', + ATTR_OUTSIDE_TEMPERATURE: 'otemp', + ATTR_TARGET_TEMPERATURE: 'stemp' +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Daikin HVAC platform.""" + if discovery_info is not None: + host = discovery_info.get('ip') + name = None + _LOGGER.debug("Discovered a Daikin AC on %s", host) + else: + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + _LOGGER.debug("Added Daikin AC on %s", host) + + api = daikin_api_setup(hass, host, name) + add_devices([DaikinClimate(api)], True) + + +class DaikinClimate(ClimateDevice): + """Representation of a Daikin HVAC.""" + + def __init__(self, api): + """Initialize the climate device.""" + from pydaikin import appliance + + self._api = api + self._force_refresh = False + self._list = { + ATTR_OPERATION_MODE: list( + map(str.title, set(HA_STATE_TO_DAIKIN.values())) + ), + ATTR_FAN_MODE: list( + map( + str.title, + appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE]) + ) + ), + ATTR_SWING_MODE: list( + map( + str.title, + appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE]) + ) + ), + } + + self._supported_features = SUPPORT_TARGET_TEMPERATURE \ + | SUPPORT_OPERATION_MODE + + daikin_attr = HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE] + if self._api.device.values.get(daikin_attr) is not None: + self._supported_features |= SUPPORT_FAN_MODE + else: + # even devices without support must have a default valid value + self._api.device.values[daikin_attr] = 'A' + + daikin_attr = HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE] + if self._api.device.values.get(daikin_attr) is not None: + self._supported_features |= SUPPORT_SWING_MODE + else: + # even devices without support must have a default valid value + self._api.device.values[daikin_attr] = '0' + + def get(self, key): + """Retrieve device settings from API library cache.""" + value = None + cast_to_float = False + + if key in [ATTR_TEMPERATURE, ATTR_INSIDE_TEMPERATURE, + ATTR_CURRENT_TEMPERATURE]: + key = ATTR_INSIDE_TEMPERATURE + + daikin_attr = HA_ATTR_TO_DAIKIN.get(key) + + if key == ATTR_INSIDE_TEMPERATURE: + value = self._api.device.values.get(daikin_attr) + cast_to_float = True + elif key == ATTR_TARGET_TEMPERATURE: + value = self._api.device.values.get(daikin_attr) + cast_to_float = True + elif key == ATTR_OUTSIDE_TEMPERATURE: + value = self._api.device.values.get(daikin_attr) + cast_to_float = True + elif key == ATTR_FAN_MODE: + value = self._api.device.represent(daikin_attr)[1].title() + elif key == ATTR_SWING_MODE: + value = self._api.device.represent(daikin_attr)[1].title() + elif key == ATTR_OPERATION_MODE: + # Daikin can return also internal states auto-1 or auto-7 + # and we need to translate them as AUTO + value = re.sub( + '[^a-z]', + '', + self._api.device.represent(daikin_attr)[1] + ).title() + + if value is None: + _LOGGER.error("Invalid value requested for key %s", key) + else: + if value == "-" or value == "--": + value = None + elif cast_to_float: + try: + value = float(value) + except ValueError: + value = None + + return value + + def set(self, settings): + """Set device settings using API.""" + values = {} + + for attr in [ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE, + ATTR_OPERATION_MODE]: + value = settings.get(attr) + if value is None: + continue + + daikin_attr = HA_ATTR_TO_DAIKIN.get(attr) + if daikin_attr is not None: + if value.title() in self._list[attr]: + values[daikin_attr] = value.lower() + else: + _LOGGER.error("Invalid value %s for %s", attr, value) + + # temperature + elif attr == ATTR_TEMPERATURE: + try: + values['stemp'] = str(int(value)) + except ValueError: + _LOGGER.error("Invalid temperature %s", value) + + if values: + self._force_refresh = True + self._api.device.set(values) + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._supported_features + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._api.name + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.get(ATTR_CURRENT_TEMPERATURE) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.get(ATTR_TARGET_TEMPERATURE) + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + self.set(kwargs) + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self.get(ATTR_OPERATION_MODE) + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._list.get(ATTR_OPERATION_MODE) + + def set_operation_mode(self, operation_mode): + """Set HVAC mode.""" + self.set({ATTR_OPERATION_MODE: operation_mode}) + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self.get(ATTR_FAN_MODE) + + def set_fan_mode(self, fan_mode): + """Set fan mode.""" + self.set({ATTR_FAN_MODE: fan_mode}) + + @property + def fan_list(self): + """List of available fan modes.""" + return self._list.get(ATTR_FAN_MODE) + + @property + def current_swing_mode(self): + """Return the fan setting.""" + return self.get(ATTR_SWING_MODE) + + def set_swing_mode(self, swing_mode): + """Set new target temperature.""" + self.set({ATTR_SWING_MODE: swing_mode}) + + @property + def swing_list(self): + """List of available swing modes.""" + return self._list.get(ATTR_SWING_MODE) + + def update(self): + """Retrieve latest state.""" + self._api.update(no_throttle=self._force_refresh) + self._force_refresh = False diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 24b40af7eb1f8..44491b8cd21e4 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -5,19 +5,28 @@ https://home-assistant.io/components/demo/ """ from homeassistant.components.climate import ( - ClimateDevice, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW) + ClimateDevice, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_HUMIDITY_LOW, SUPPORT_TARGET_HUMIDITY_HIGH, + SUPPORT_AWAY_MODE, SUPPORT_HOLD_MODE, SUPPORT_FAN_MODE, + SUPPORT_OPERATION_MODE, SUPPORT_AUX_HEAT, SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW, + SUPPORT_ON_OFF) from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE +SUPPORT_FLAGS = SUPPORT_TARGET_HUMIDITY_LOW | SUPPORT_TARGET_HUMIDITY_HIGH + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo climate devices.""" add_devices([ DemoClimate('HeatPump', 68, TEMP_FAHRENHEIT, None, None, 77, - 'Auto Low', None, None, 'Auto', 'heat', None, None, None), + None, None, None, None, 'heat', None, None, + None, True), DemoClimate('Hvac', 21, TEMP_CELSIUS, True, None, 22, 'On High', - 67, 54, 'Off', 'cool', False, None, None), - DemoClimate('Ecobee', None, TEMP_CELSIUS, None, None, 23, 'Auto Low', - None, None, 'Auto', 'auto', None, 24, 21) + 67, 54, 'Off', 'cool', False, None, None, None), + DemoClimate('Ecobee', None, TEMP_CELSIUS, None, 'home', 23, 'Auto Low', + None, None, 'Auto', 'auto', None, 24, 21, None) ]) @@ -27,9 +36,37 @@ class DemoClimate(ClimateDevice): def __init__(self, name, target_temperature, unit_of_measurement, away, hold, current_temperature, current_fan_mode, target_humidity, current_humidity, current_swing_mode, - current_operation, aux, target_temp_high, target_temp_low): + current_operation, aux, target_temp_high, target_temp_low, + is_on): """Initialize the climate device.""" self._name = name + self._support_flags = SUPPORT_FLAGS + if target_temperature is not None: + self._support_flags = \ + self._support_flags | SUPPORT_TARGET_TEMPERATURE + if away is not None: + self._support_flags = self._support_flags | SUPPORT_AWAY_MODE + if hold is not None: + self._support_flags = self._support_flags | SUPPORT_HOLD_MODE + if current_fan_mode is not None: + self._support_flags = self._support_flags | SUPPORT_FAN_MODE + if target_humidity is not None: + self._support_flags = \ + self._support_flags | SUPPORT_TARGET_HUMIDITY + if current_swing_mode is not None: + self._support_flags = self._support_flags | SUPPORT_SWING_MODE + if current_operation is not None: + self._support_flags = self._support_flags | SUPPORT_OPERATION_MODE + if aux is not None: + self._support_flags = self._support_flags | SUPPORT_AUX_HEAT + if target_temp_high is not None: + self._support_flags = \ + self._support_flags | SUPPORT_TARGET_TEMPERATURE_HIGH + if target_temp_low is not None: + self._support_flags = \ + self._support_flags | SUPPORT_TARGET_TEMPERATURE_LOW + if is_on is not None: + self._support_flags = self._support_flags | SUPPORT_ON_OFF self._target_temperature = target_temperature self._target_humidity = target_humidity self._unit_of_measurement = unit_of_measurement @@ -46,6 +83,12 @@ def __init__(self, name, target_temperature, unit_of_measurement, self._swing_list = ['Auto', '1', '2', '3', 'Off'] self._target_temperature_high = target_temp_high self._target_temperature_low = target_temp_low + self._on = is_on + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags @property def should_poll(self): @@ -114,9 +157,14 @@ def current_hold_mode(self): @property def is_aux_heat_on(self): - """Return true if away mode is on.""" + """Return true if aux heat is on.""" return self._aux + @property + def is_on(self): + """Return true if the device is on.""" + return self._on + @property def current_fan_mode(self): """Return the fan setting.""" @@ -147,9 +195,9 @@ def set_swing_mode(self, swing_mode): self._current_swing_mode = swing_mode self.schedule_update_ha_state() - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target temperature.""" - self._current_fan_mode = fan + self._current_fan_mode = fan_mode self.schedule_update_ha_state() def set_operation_mode(self, operation_mode): @@ -177,17 +225,27 @@ def turn_away_mode_off(self): self._away = False self.schedule_update_ha_state() - def set_hold_mode(self, hold): - """Update hold mode on.""" - self._hold = hold + def set_hold_mode(self, hold_mode): + """Update hold_mode on.""" + self._hold = hold_mode self.schedule_update_ha_state() def turn_aux_heat_on(self): - """Turn away auxillary heater on.""" + """Turn auxiliary heater on.""" self._aux = True self.schedule_update_ha_state() def turn_aux_heat_off(self): - """Turn auxillary heater off.""" + """Turn auxiliary heater off.""" self._aux = False self.schedule_update_ha_state() + + def turn_on(self): + """Turn on.""" + self._on = True + self.schedule_update_ha_state() + + def turn_off(self): + """Turn off.""" + self._on = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index 6780d3745f05e..e64c2d5000e7f 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -5,17 +5,19 @@ https://home-assistant.io/components/climate.ecobee/ """ import logging -from os import path import voluptuous as vol from homeassistant.components import ecobee from homeassistant.components.climate import ( DOMAIN, STATE_COOL, STATE_HEAT, STATE_AUTO, STATE_IDLE, ClimateDevice, - ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH) + 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_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE_LOW, STATE_OFF) from homeassistant.const import ( - ATTR_ENTITY_ID, STATE_OFF, STATE_ON, ATTR_TEMPERATURE, TEMP_FAHRENHEIT) -from homeassistant.config import load_yaml_config_file + ATTR_ENTITY_ID, STATE_ON, ATTR_TEMPERATURE, TEMP_FAHRENHEIT) import homeassistant.helpers.config_validation as cv _CONFIGURING = {} @@ -27,6 +29,7 @@ DEFAULT_RESUME_ALL = False TEMPERATURE_HOLD = 'temp' VACATION_HOLD = 'vacation' +AWAY_MODE = 'awayMode' DEPENDENCIES = ['ecobee'] @@ -43,6 +46,12 @@ vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean, }) +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_FAN_MODE) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Ecobee Thermostat Platform.""" @@ -89,17 +98,12 @@ def resume_program_set_service(service): thermostat.schedule_update_ha_state(True) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - hass.services.register( DOMAIN, SERVICE_SET_FAN_MIN_ON_TIME, fan_min_on_time_set_service, - descriptions.get(SERVICE_SET_FAN_MIN_ON_TIME), schema=SET_FAN_MIN_ON_TIME_SCHEMA) hass.services.register( DOMAIN, SERVICE_RESUME_PROGRAM, resume_program_set_service, - descriptions.get(SERVICE_RESUME_PROGRAM), schema=RESUME_PROGRAM_SCHEMA) @@ -118,6 +122,7 @@ def __init__(self, data, thermostat_index, hold_temp): 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): @@ -131,6 +136,11 @@ def update(self): self.thermostat = self.data.ecobee.get_thermostat( self.thermostat_index) + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + @property def name(self): """Return the name of the Ecobee Thermostat.""" @@ -144,20 +154,20 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - return self.thermostat['runtime']['actualTemperature'] / 10 + return self.thermostat['runtime']['actualTemperature'] / 10.0 @property def target_temperature_low(self): """Return the lower bound temperature we try to reach.""" if self.current_operation == STATE_AUTO: - return int(self.thermostat['runtime']['desiredHeat'] / 10) + return self.thermostat['runtime']['desiredHeat'] / 10.0 return None @property def target_temperature_high(self): """Return the upper bound temperature we try to reach.""" if self.current_operation == STATE_AUTO: - return int(self.thermostat['runtime']['desiredCool'] / 10) + return self.thermostat['runtime']['desiredCool'] / 10.0 return None @property @@ -166,37 +176,47 @@ def target_temperature(self): if self.current_operation == STATE_AUTO: return None if self.current_operation == STATE_HEAT: - return int(self.thermostat['runtime']['desiredHeat'] / 10) + return self.thermostat['runtime']['desiredHeat'] / 10.0 elif self.current_operation == STATE_COOL: - return int(self.thermostat['runtime']['desiredCool'] / 10) + 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'] for event in events: if event['running']: 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 is away_mode - return None + # A permanent hold from away climate + return AWAY_MODE elif event['holdClimateRef'] != "": # Any other hold based on climate return event['holdClimateRef'] @@ -214,7 +234,7 @@ def current_hold_mode(self): 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 @@ -257,10 +277,11 @@ def device_state_attributes(self): 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 @@ -269,7 +290,7 @@ def device_state_attributes(self): @property def is_away_mode_on(self): """Return true if away mode is on.""" - return self.current_hold_mode == 'away' + return self._current_hold_mode == AWAY_MODE @property def is_aux_heat_on(self): @@ -277,12 +298,17 @@ def is_aux_heat_on(self): return 'auxHeat' in self.thermostat['equipmentStatus'] def turn_away_mode_on(self): - """Turn away on.""" - self.set_hold_mode('away') + """Turn away mode on by setting it on away hold indefinitely.""" + if self._current_hold_mode != AWAY_MODE: + self.data.ecobee.set_climate_hold(self.thermostat_index, 'away', + 'indefinite') + self.update_without_throttle = True def turn_away_mode_off(self): """Turn away off.""" - self.set_hold_mode(None) + if self._current_hold_mode == AWAY_MODE: + self.data.ecobee.resume_program(self.thermostat_index) + self.update_without_throttle = True def set_hold_mode(self, hold_mode): """Set hold mode (away, home, temp, sleep, etc.).""" @@ -299,7 +325,7 @@ def set_hold_mode(self, hold_mode): self.data.ecobee.resume_program(self.thermostat_index) else: if hold_mode == TEMPERATURE_HOLD: - self.set_temp_hold(int(self.current_temperature)) + self.set_temp_hold(self.current_temperature) else: self.data.ecobee.set_climate_hold( self.thermostat_index, hold_mode, self.hold_preference()) @@ -307,33 +333,63 @@ def set_hold_mode(self, hold_mode): def set_auto_temp_hold(self, heat_temp, cool_temp): """Set temperature hold in auto mode.""" - self.data.ecobee.set_hold_temp(self.thermostat_index, cool_temp, - heat_temp, self.hold_preference()) + if cool_temp is not None: + cool_temp_setpoint = cool_temp + else: + cool_temp_setpoint = ( + self.thermostat['runtime']['desiredCool'] / 10.0) + + if heat_temp is not None: + heat_temp_setpoint = heat_temp + else: + heat_temp_setpoint = ( + self.thermostat['runtime']['desiredCool'] / 10.0) + + self.data.ecobee.set_hold_temp(self.thermostat_index, + 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 - - self.data.ecobee.set_hold_temp(self.thermostat_index, cool_temp, - heat_temp, self.hold_preference()) - _LOGGER.debug("Setting ecobee hold_temp to: low=%s, is=%s, " - "cool=%s, is=%s", heat_temp, isinstance( - heat_temp, (int, float)), cool_temp, - isinstance(cool_temp, (int, float))) - - self.update_without_throttle = True + else: + 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): """Set new target temperature.""" @@ -341,15 +397,19 @@ def set_temperature(self, **kwargs): 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 \ - and high_temp is not None: - self.set_auto_temp_hold(int(low_temp), int(high_temp)) + 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(int(temp)) + self.set_temp_hold(temp) else: _LOGGER.error( "Missing valid arguments for set_temperature in %s", kwargs) + def set_humidity(self, humidity): + """Set the humidity level.""" + self.data.ecobee.set_humidity(self.thermostat_index, humidity) + def set_operation_mode(self, operation_mode): """Set HVAC mode (auto, auxHeatOnly, cool, heat, off).""" self.data.ecobee.set_hvac_mode(self.thermostat_index, operation_mode) @@ -364,7 +424,7 @@ def set_fan_min_on_time(self, fan_min_on_time): def resume_program(self, resume_all): """Resume the thermostat schedule program.""" self.data.ecobee.resume_program( - self.thermostat_index, str(resume_all).lower()) + self.thermostat_index, 'true' if resume_all else 'false') self.update_without_throttle = True def hold_preference(self): diff --git a/homeassistant/components/climate/econet.py b/homeassistant/components/climate/econet.py new file mode 100644 index 0000000000000..0591178391a3f --- /dev/null +++ b/homeassistant/components/climate/econet.py @@ -0,0 +1,222 @@ +""" +Support for Rheem EcoNet water heaters. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.econet/ +""" +import datetime +import logging + +import voluptuous as vol + +from homeassistant.components.climate import ( + DOMAIN, PLATFORM_SCHEMA, STATE_ECO, STATE_ELECTRIC, STATE_GAS, + STATE_HEAT_PUMP, STATE_HIGH_DEMAND, STATE_OFF, STATE_PERFORMANCE, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_PASSWORD, CONF_USERNAME, + TEMP_FAHRENHEIT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyeconet==0.0.5'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_VACATION_START = 'next_vacation_start_date' +ATTR_VACATION_END = 'next_vacation_end_date' +ATTR_ON_VACATION = 'on_vacation' +ATTR_TODAYS_ENERGY_USAGE = 'todays_energy_usage' +ATTR_IN_USE = 'in_use' + +ATTR_START_DATE = 'start_date' +ATTR_END_DATE = 'end_date' + +SUPPORT_FLAGS_HEATER = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE) + +SERVICE_ADD_VACATION = 'econet_add_vacation' +SERVICE_DELETE_VACATION = 'econet_delete_vacation' + +ADD_VACATION_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_START_DATE): cv.positive_int, + vol.Required(ATTR_END_DATE): cv.positive_int, +}) + +DELETE_VACATION_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +ECONET_DATA = 'econet' + +HA_STATE_TO_ECONET = { + STATE_ECO: 'Energy Saver', + STATE_ELECTRIC: 'Electric', + STATE_HEAT_PUMP: 'Heat Pump', + STATE_GAS: 'gas', + STATE_HIGH_DEMAND: 'High Demand', + STATE_OFF: 'Off', + STATE_PERFORMANCE: 'Performance' +} + +ECONET_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_ECONET.items()} + +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 EcoNet water heaters.""" + from pyeconet.api import PyEcoNet + + hass.data[ECONET_DATA] = {} + hass.data[ECONET_DATA]['water_heaters'] = [] + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + econet = PyEcoNet(username, password) + water_heaters = econet.get_water_heaters() + hass_water_heaters = [ + EcoNetWaterHeater(water_heater) for water_heater in water_heaters] + add_devices(hass_water_heaters) + hass.data[ECONET_DATA]['water_heaters'].extend(hass_water_heaters) + + def service_handle(service): + """Handle the service calls.""" + entity_ids = service.data.get('entity_id') + all_heaters = hass.data[ECONET_DATA]['water_heaters'] + _heaters = [ + x for x in all_heaters + if not entity_ids or x.entity_id in entity_ids] + + for _water_heater in _heaters: + if service.service == SERVICE_ADD_VACATION: + start = service.data.get(ATTR_START_DATE) + end = service.data.get(ATTR_END_DATE) + _water_heater.add_vacation(start, end) + if service.service == SERVICE_DELETE_VACATION: + for vacation in _water_heater.water_heater.vacations: + vacation.delete() + + _water_heater.schedule_update_ha_state(True) + + hass.services.register(DOMAIN, SERVICE_ADD_VACATION, service_handle, + schema=ADD_VACATION_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_DELETE_VACATION, service_handle, + schema=DELETE_VACATION_SCHEMA) + + +class EcoNetWaterHeater(ClimateDevice): + """Representation of an EcoNet water heater.""" + + def __init__(self, water_heater): + """Initialize the water heater.""" + self.water_heater = water_heater + + @property + def name(self): + """Return the device name.""" + return self.water_heater.name + + @property + def available(self): + """Return if the the device is online or not.""" + return self.water_heater.is_connected + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def device_state_attributes(self): + """Return the optional device state attributes.""" + data = {} + vacations = self.water_heater.get_vacations() + if vacations: + data[ATTR_VACATION_START] = vacations[0].start_date + data[ATTR_VACATION_END] = vacations[0].end_date + data[ATTR_ON_VACATION] = self.water_heater.is_on_vacation + todays_usage = self.water_heater.total_usage_for_today + if todays_usage: + data[ATTR_TODAYS_ENERGY_USAGE] = todays_usage + data[ATTR_IN_USE] = self.water_heater.in_use + + return data + + @property + def current_operation(self): + """ + Return current operation as one of the following. + + ["eco", "heat_pump", "high_demand", "electric_only"] + """ + current_op = ECONET_STATE_TO_HA.get(self.water_heater.mode) + return current_op + + @property + def operation_list(self): + """List of available operation modes.""" + op_list = [] + modes = self.water_heater.supported_modes + for mode in modes: + ha_mode = ECONET_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invalid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) + return op_list + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS_HEATER + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + if target_temp is not None: + self.water_heater.set_target_set_point(target_temp) + else: + _LOGGER.error("A target temperature must be provided") + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + op_mode_to_set = HA_STATE_TO_ECONET.get(operation_mode) + if op_mode_to_set is not None: + self.water_heater.set_mode(op_mode_to_set) + else: + _LOGGER.error("An operation mode must be provided") + + def add_vacation(self, start, end): + """Add a vacation to this water heater.""" + if not start: + start = datetime.datetime.now() + else: + start = datetime.datetime.fromtimestamp(start) + end = datetime.datetime.fromtimestamp(end) + self.water_heater.set_vacation_mode(start, end) + + def update(self): + """Get the latest date.""" + self.water_heater.update_state() + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.water_heater.set_point + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self.water_heater.min_set_point + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.water_heater.max_set_point diff --git a/homeassistant/components/climate/ephember.py b/homeassistant/components/climate/ephember.py new file mode 100644 index 0000000000000..419237b4645ae --- /dev/null +++ b/homeassistant/components/climate/ephember.py @@ -0,0 +1,154 @@ +""" +Support for the EPH Controls Ember themostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.ephember/ +""" +import logging +from datetime import timedelta +import voluptuous as vol + +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, STATE_HEAT, STATE_IDLE, SUPPORT_AUX_HEAT, + SUPPORT_TARGET_TEMPERATURE) +from homeassistant.const import ( + TEMP_CELSIUS, CONF_USERNAME, CONF_PASSWORD, ATTR_TEMPERATURE) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyephember==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +# Return cached results if last scan was less then this time ago +SCAN_INTERVAL = timedelta(seconds=120) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the ephember thermostat.""" + from pyephember.pyephember import EphEmber + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + try: + ember = EphEmber(username, password) + zones = ember.get_zones() + for zone in zones: + add_devices([EphEmberThermostat(ember, zone)]) + except RuntimeError: + _LOGGER.error("Cannot connect to EphEmber") + return + + return + + +class EphEmberThermostat(ClimateDevice): + """Representation of a HeatmiserV3 thermostat.""" + + def __init__(self, ember, zone): + """Initialize the thermostat.""" + self._ember = ember + self._zone_name = zone['name'] + self._zone = zone + self._hot_water = zone['isHotWater'] + + @property + def supported_features(self): + """Return the list of supported features.""" + if self._hot_water: + return SUPPORT_AUX_HEAT + + return SUPPORT_TARGET_TEMPERATURE | SUPPORT_AUX_HEAT + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._zone_name + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._zone['currentTemperature'] + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._zone['targetTemperature'] + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + if self._hot_water: + return None + + return 1 + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + if self._zone['isCurrentlyActive']: + return STATE_HEAT + return STATE_IDLE + + @property + def is_aux_heat_on(self): + """Return true if aux heater.""" + return self._zone['isBoostActive'] + + def turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + self._ember.activate_boost_by_name( + self._zone_name, self._zone['targetTemperature']) + + def turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + self._ember.deactivate_boost_by_name(self._zone_name) + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + if self._hot_water: + return + + if temperature == self.target_temperature: + return + + if temperature > self.max_temp or temperature < self.min_temp: + return + + self._ember.set_target_temperture_by_name(self._zone_name, + int(temperature)) + + @property + def min_temp(self): + """Return the minimum temperature.""" + # Hot water temp doesn't support being changed + if self._hot_water: + return self._zone['targetTemperature'] + + return 5 + + @property + def max_temp(self): + """Return the maximum temperature.""" + if self._hot_water: + return self._zone['targetTemperature'] + + return 35 + + def update(self): + """Get the latest data.""" + self._zone = self._ember.get_zone(self._zone_name) diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index ff13dd48cac40..820e715b00d11 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -9,15 +9,13 @@ import voluptuous as vol from homeassistant.components.climate import ( - ClimateDevice, PLATFORM_SCHEMA, PRECISION_HALVES, - STATE_AUTO, STATE_ON, STATE_OFF, -) + STATE_ON, STATE_OFF, STATE_AUTO, PLATFORM_SCHEMA, ClimateDevice, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE) from homeassistant.const import ( - CONF_MAC, TEMP_CELSIUS, CONF_DEVICES, ATTR_TEMPERATURE) - + CONF_MAC, CONF_DEVICES, TEMP_CELSIUS, ATTR_TEMPERATURE, PRECISION_HALVES) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-eq3bt==0.1.5'] +REQUIREMENTS = ['python-eq3bt==0.1.9', 'construct==2.9.41'] _LOGGER = logging.getLogger(__name__) @@ -40,6 +38,9 @@ vol.Schema({cv.string: DEVICE_SCHEMA}), }) +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | + SUPPORT_AWAY_MODE) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the eQ-3 BLE thermostats.""" @@ -52,26 +53,35 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(devices) -# pylint: disable=import-error +# pylint: disable=import-error, no-name-in-module class EQ3BTSmartThermostat(ClimateDevice): - """Representation of a eQ-3 Bluetooth Smart thermostat.""" + """Representation of an eQ-3 Bluetooth Smart thermostat.""" def __init__(self, _mac, _name): """Initialize the thermostat.""" - # we want to avoid name clash with this module.. + # We want to avoid name clash with this module. import eq3bt as eq3 - self.modes = {eq3.Mode.Open: STATE_ON, - eq3.Mode.Closed: STATE_OFF, - eq3.Mode.Auto: STATE_AUTO, - eq3.Mode.Manual: STATE_MANUAL, - eq3.Mode.Boost: STATE_BOOST, - eq3.Mode.Away: STATE_AWAY} + self.modes = { + eq3.Mode.Open: STATE_ON, + eq3.Mode.Closed: STATE_OFF, + eq3.Mode.Auto: STATE_AUTO, + eq3.Mode.Manual: STATE_MANUAL, + eq3.Mode.Boost: STATE_BOOST, + eq3.Mode.Away: STATE_AWAY, + } self.reverse_modes = {v: k for k, v in self.modes.items()} self._name = _name self._thermostat = eq3.Thermostat(_mac) + self._target_temperature = None + self._target_mode = None + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS @property def available(self) -> bool: @@ -108,6 +118,7 @@ def set_temperature(self, **kwargs): temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return + self._target_temperature = temperature self._thermostat.target_temperature = temperature @property @@ -124,6 +135,7 @@ def operation_list(self): def set_operation_mode(self, operation_mode): """Set operation mode.""" + self._target_mode = operation_mode self._thermostat.mode = self.reverse_modes[operation_mode] def turn_away_mode_off(self): @@ -153,15 +165,31 @@ def max_temp(self): def device_state_attributes(self): """Return the device specific state attributes.""" dev_specific = { + ATTR_STATE_AWAY_END: self._thermostat.away_end, ATTR_STATE_LOCKED: self._thermostat.locked, ATTR_STATE_LOW_BAT: self._thermostat.low_battery, ATTR_STATE_VALVE: self._thermostat.valve_state, ATTR_STATE_WINDOW_OPEN: self._thermostat.window_open, - ATTR_STATE_AWAY_END: self._thermostat.away_end, } return dev_specific def update(self): """Update the data from the thermostat.""" - self._thermostat.update() + from bluepy.btle import BTLEException + try: + self._thermostat.update() + except BTLEException as ex: + _LOGGER.warning("Updating the state failed: %s", ex) + + if (self._target_temperature and + self._thermostat.target_temperature + != self._target_temperature): + self.set_temperature(temperature=self._target_temperature) + else: + self._target_temperature = None + if (self._target_mode and + self.modes[self._thermostat.mode] != self._target_mode): + self.set_operation_mode(operation_mode=self._target_mode) + else: + self._target_mode = None diff --git a/homeassistant/components/climate/flexit.py b/homeassistant/components/climate/flexit.py index c3ba2224b06a7..565e913319f4c 100644 --- a/homeassistant/components/climate/flexit.py +++ b/homeassistant/components/climate/flexit.py @@ -17,7 +17,9 @@ from homeassistant.const import ( CONF_NAME, CONF_SLAVE, TEMP_CELSIUS, ATTR_TEMPERATURE, DEVICE_DEFAULT_NAME) -from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA) +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_FAN_MODE) import homeassistant.components.modbus as modbus import homeassistant.helpers.config_validation as cv @@ -31,6 +33,8 @@ _LOGGER = logging.getLogger(__name__) +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Flexit Platform.""" @@ -62,6 +66,11 @@ def __init__(self, modbus_slave, name): self._alarm = False self.unit = pyflexit.pyflexit(modbus.HUB, modbus_slave) + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + def update(self): """Update unit attributes.""" if not self.unit.update(): @@ -143,6 +152,6 @@ def set_temperature(self, **kwargs): self._target_temperature = kwargs.get(ATTR_TEMPERATURE) self.unit.set_temp(self._target_temperature) - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new fan mode.""" - self.unit.set_fan_speed(self._fan_list.index(fan)) + self.unit.set_fan_speed(self._fan_list.index(fan_mode)) diff --git a/homeassistant/components/climate/fritzbox.py b/homeassistant/components/climate/fritzbox.py new file mode 100755 index 0000000000000..839da8c9d5333 --- /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 9442b7da19446..b5d3c3f7c253a 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -10,17 +10,20 @@ import voluptuous as vol from homeassistant.core import callback -from homeassistant.components import switch +from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.components.climate import ( - STATE_HEAT, STATE_COOL, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA, - STATE_AUTO) + 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) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE, - CONF_NAME) + CONF_NAME, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, + STATE_UNKNOWN) from homeassistant.helpers import condition from homeassistant.helpers.event import ( async_track_state_change, async_track_time_interval) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) @@ -36,9 +39,13 @@ CONF_TARGET_TEMP = 'target_temp' CONF_AC_MODE = 'ac_mode' CONF_MIN_DUR = 'min_cycle_duration' -CONF_TOLERANCE = 'tolerance' +CONF_COLD_TOLERANCE = 'cold_tolerance' +CONF_HOT_TOLERANCE = 'hot_tolerance' CONF_KEEP_ALIVE = 'keep_alive' - +CONF_INITIAL_OPERATION_MODE = 'initial_operation_mode' +CONF_AWAY_TEMP = 'away_temp' +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_OPERATION_MODE) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HEATER): cv.entity_id, @@ -48,10 +55,16 @@ vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), + vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce( + float), + vol.Optional(CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce( + float), vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), vol.Optional(CONF_KEEP_ALIVE): vol.All( cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_INITIAL_OPERATION_MODE): + vol.In([STATE_AUTO, STATE_OFF]), + vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float) }) @@ -66,12 +79,16 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): target_temp = config.get(CONF_TARGET_TEMP) ac_mode = config.get(CONF_AC_MODE) min_cycle_duration = config.get(CONF_MIN_DUR) - tolerance = config.get(CONF_TOLERANCE) + cold_tolerance = config.get(CONF_COLD_TOLERANCE) + hot_tolerance = config.get(CONF_HOT_TOLERANCE) keep_alive = config.get(CONF_KEEP_ALIVE) + initial_operation_mode = config.get(CONF_INITIAL_OPERATION_MODE) + away_temp = config.get(CONF_AWAY_TEMP) async_add_devices([GenericThermostat( hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, - target_temp, ac_mode, min_cycle_duration, tolerance, keep_alive)]) + target_temp, ac_mode, min_cycle_duration, cold_tolerance, + hot_tolerance, keep_alive, initial_operation_mode, away_temp)]) class GenericThermostat(ClimateDevice): @@ -79,23 +96,42 @@ class GenericThermostat(ClimateDevice): def __init__(self, hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, - tolerance, keep_alive): + cold_tolerance, hot_tolerance, keep_alive, + initial_operation_mode, away_temp): """Initialize the thermostat.""" self.hass = hass self._name = name self.heater_entity_id = heater_entity_id self.ac_mode = ac_mode self.min_cycle_duration = min_cycle_duration - self._tolerance = tolerance + self._cold_tolerance = cold_tolerance + self._hot_tolerance = hot_tolerance self._keep_alive = keep_alive - self._enabled = True - + self._initial_operation_mode = initial_operation_mode + self._saved_target_temp = target_temp if target_temp is not None \ + else away_temp + if self.ac_mode: + self._current_operation = STATE_COOL + self._operation_list = [STATE_COOL, STATE_OFF] + else: + self._current_operation = STATE_HEAT + self._operation_list = [STATE_HEAT, STATE_OFF] + if initial_operation_mode == STATE_OFF: + self._enabled = False + self._current_operation = STATE_OFF + else: + self._enabled = True self._active = False self._cur_temp = None self._min_temp = min_temp self._max_temp = max_temp self._target_temp = target_temp self._unit = hass.config.units.temperature_unit + self._support_flags = SUPPORT_FLAGS + if away_temp is not None: + self._support_flags = SUPPORT_FLAGS | SUPPORT_AWAY_MODE + self._away_temp = away_temp + self._is_away = False async_track_state_change( hass, sensor_entity_id, self._async_sensor_changed) @@ -107,9 +143,57 @@ def __init__(self, hass, name, heater_entity_id, sensor_entity_id, hass, self._async_keep_alive, self._keep_alive) sensor_state = hass.states.get(sensor_entity_id) - if sensor_state: + if sensor_state and sensor_state.state != STATE_UNKNOWN: self._async_update_temp(sensor_state) + @asyncio.coroutine + def async_added_to_hass(self): + """Run when entity about to be added.""" + # Check If we have an old state + old_state = yield from async_get_last_state(self.hass, + self.entity_id) + if old_state is not None: + # If we have no initial temperature, restore + if self._target_temp is None: + # If we have a previously saved temperature + if old_state.attributes.get(ATTR_TEMPERATURE) is None: + if self.ac_mode: + self._target_temp = self.max_temp + else: + self._target_temp = self.min_temp + _LOGGER.warning("Undefined target temperature," + "falling back to %s", self._target_temp) + else: + self._target_temp = float( + old_state.attributes[ATTR_TEMPERATURE]) + if old_state.attributes.get(ATTR_AWAY_MODE) is not None: + self._is_away = str( + old_state.attributes[ATTR_AWAY_MODE]) == STATE_ON + if (self._initial_operation_mode is None and + old_state.attributes[ATTR_OPERATION_MODE] is not None): + self._current_operation = \ + old_state.attributes[ATTR_OPERATION_MODE] + self._enabled = self._current_operation != STATE_OFF + + else: + # No previous state, try and restore defaults + if self._target_temp is None: + if self.ac_mode: + self._target_temp = self.max_temp + else: + self._target_temp = self.min_temp + _LOGGER.warning("No previously saved temperature, setting to %s", + self._target_temp) + + @property + def state(self): + """Return the current state.""" + if self._is_device_active: + return self.current_operation + if self._enabled: + return STATE_IDLE + return STATE_OFF + @property def should_poll(self): """Return the polling state.""" @@ -132,15 +216,8 @@ def current_temperature(self): @property def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - if not self._enabled: - return STATE_OFF - if self.ac_mode: - cooling = self._active and self._is_device_active - return STATE_COOL if cooling else STATE_IDLE - - heating = self._active and self._is_device_active - return STATE_HEAT if heating else STATE_IDLE + """Return current operation.""" + return self._current_operation @property def target_temperature(self): @@ -150,20 +227,27 @@ def target_temperature(self): @property def operation_list(self): """List of available operation modes.""" - return [STATE_AUTO, STATE_OFF] + return self._operation_list - def set_operation_mode(self, operation_mode): + async def async_set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_AUTO: + if operation_mode == STATE_HEAT: + self._current_operation = STATE_HEAT + self._enabled = True + self._async_control_heating() + elif operation_mode == STATE_COOL: + self._current_operation = STATE_COOL self._enabled = True + self._async_control_heating() elif operation_mode == STATE_OFF: + self._current_operation = STATE_OFF self._enabled = False if self._is_device_active: - switch.async_turn_off(self.hass, self.heater_entity_id) + self._heater_turn_off() else: - _LOGGER.error('Unrecognized operation mode: %s', operation_mode) + _LOGGER.error("Unrecognized operation mode: %s", operation_mode) return - # Ensure we updae the current operation after changing the mode + # Ensure we update the current operation after changing the mode self.schedule_update_ha_state() @asyncio.coroutine @@ -211,15 +295,15 @@ def _async_switch_changed(self, entity_id, old_state, new_state): """Handle heater switch state changes.""" if new_state is None: return - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def _async_keep_alive(self, time): """Call at constant intervals for keep-alive purposes.""" - if self.current_operation in [STATE_COOL, STATE_HEAT]: - switch.async_turn_on(self.hass, self.heater_entity_id) + if self._is_device_active: + self._heater_turn_on() else: - switch.async_turn_off(self.hass, self.heater_entity_id) + self._heater_turn_off() @callback def _async_update_temp(self, state): @@ -230,7 +314,7 @@ def _async_update_temp(self, state): self._cur_temp = self.hass.config.units.temperature( float(state.state), unit) except ValueError as ex: - _LOGGER.error('Unable to update from sensor: %s', ex) + _LOGGER.error("Unable to update from sensor: %s", ex) @callback def _async_control_heating(self): @@ -238,8 +322,9 @@ def _async_control_heating(self): if not self._active and None not in (self._cur_temp, self._target_temp): self._active = True - _LOGGER.info('Obtained current and target temperature. ' - 'Generic thermostat active.') + _LOGGER.info("Obtained current and target temperature. " + "Generic thermostat active. %s, %s", + self._cur_temp, self._target_temp) if not self._active: return @@ -261,30 +346,73 @@ def _async_control_heating(self): if self.ac_mode: is_cooling = self._is_device_active if is_cooling: - too_cold = self._target_temp - self._cur_temp > self._tolerance + too_cold = self._target_temp - self._cur_temp >= \ + self._cold_tolerance if too_cold: - _LOGGER.info('Turning off AC %s', self.heater_entity_id) - switch.async_turn_off(self.hass, self.heater_entity_id) + _LOGGER.info("Turning off AC %s", self.heater_entity_id) + self._heater_turn_off() else: - too_hot = self._cur_temp - self._target_temp > self._tolerance + too_hot = self._cur_temp - self._target_temp >= \ + self._hot_tolerance if too_hot: - _LOGGER.info('Turning on AC %s', self.heater_entity_id) - switch.async_turn_on(self.hass, self.heater_entity_id) + _LOGGER.info("Turning on AC %s", self.heater_entity_id) + self._heater_turn_on() else: is_heating = self._is_device_active if is_heating: - too_hot = self._cur_temp - self._target_temp > self._tolerance + too_hot = self._cur_temp - self._target_temp >= \ + self._hot_tolerance if too_hot: - _LOGGER.info('Turning off heater %s', + _LOGGER.info("Turning off heater %s", self.heater_entity_id) - switch.async_turn_off(self.hass, self.heater_entity_id) + self._heater_turn_off() else: - too_cold = self._target_temp - self._cur_temp > self._tolerance + too_cold = self._target_temp - self._cur_temp >= \ + self._cold_tolerance if too_cold: - _LOGGER.info('Turning on heater %s', self.heater_entity_id) - switch.async_turn_on(self.hass, self.heater_entity_id) + _LOGGER.info("Turning on heater %s", self.heater_entity_id) + self._heater_turn_on() @property def _is_device_active(self): """If the toggleable device is currently active.""" - return switch.is_on(self.hass, self.heater_entity_id) + return self.hass.states.is_state(self.heater_entity_id, STATE_ON) + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + @callback + def _heater_turn_on(self): + """Turn heater toggleable device on.""" + data = {ATTR_ENTITY_ID: self.heater_entity_id} + self.hass.async_add_job( + self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data)) + + @callback + def _heater_turn_off(self): + """Turn heater toggleable device off.""" + data = {ATTR_ENTITY_ID: self.heater_entity_id} + self.hass.async_add_job( + self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data)) + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._is_away + + def turn_away_mode_on(self): + """Turn away mode on by setting it on away hold indefinitely.""" + self._is_away = True + self._saved_target_temp = self._target_temp + self._target_temp = self._away_temp + self._async_control_heating() + self.schedule_update_ha_state() + + def turn_away_mode_off(self): + """Turn away off.""" + self._is_away = False + self._target_temp = self._saved_target_temp + self._async_control_heating() + self.schedule_update_ha_state() diff --git a/homeassistant/components/climate/heatmiser.py b/homeassistant/components/climate/heatmiser.py index 56015ebeb5ad0..19c033a319f5b 100644 --- a/homeassistant/components/climate/heatmiser.py +++ b/homeassistant/components/climate/heatmiser.py @@ -8,7 +8,8 @@ import voluptuous as vol -from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE) from homeassistant.const import ( TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_PORT, CONF_NAME, CONF_ID) import homeassistant.helpers.config_validation as cv @@ -45,7 +46,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): serport = connection.connection(ipaddress, port) serport.open() - for thermostat, tstat in tstats.items(): + for tstat in tstats.values(): add_devices([ HeatmiserV3Thermostat( heatmiser, tstat.get(CONF_ID), tstat.get(CONF_NAME), serport) @@ -68,6 +69,11 @@ def __init__(self, heatmiser, device, name, serport): self.update() self._target_temperature = int(self.dcb.get('roomset')) + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + @property def name(self): """Return the name of the thermostat, if any.""" diff --git a/homeassistant/components/climate/hive.py b/homeassistant/components/climate/hive.py new file mode 100644 index 0000000000000..eb3aecae3a10b --- /dev/null +++ b/homeassistant/components/climate/hive.py @@ -0,0 +1,191 @@ +""" +Support for the Hive devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.hive/ +""" +from homeassistant.components.climate import ( + ClimateDevice, STATE_AUTO, STATE_HEAT, STATE_OFF, STATE_ON, + SUPPORT_AUX_HEAT, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.components.hive import DATA_HIVE + +DEPENDENCIES = ['hive'] +HIVE_TO_HASS_STATE = {'SCHEDULE': STATE_AUTO, 'MANUAL': STATE_HEAT, + 'ON': STATE_ON, 'OFF': STATE_OFF} +HASS_TO_HIVE_STATE = {STATE_AUTO: 'SCHEDULE', STATE_HEAT: 'MANUAL', + STATE_ON: 'ON', STATE_OFF: 'OFF'} + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_OPERATION_MODE | + SUPPORT_AUX_HEAT) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Hive climate devices.""" + if discovery_info is None: + return + session = hass.data.get(DATA_HIVE) + + add_devices([HiveClimateEntity(session, discovery_info)]) + + +class HiveClimateEntity(ClimateDevice): + """Hive Climate Device.""" + + def __init__(self, hivesession, hivedevice): + """Initialize the Climate device.""" + 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) + + if self.device_type == "Heating": + self.modes = [STATE_AUTO, STATE_HEAT, STATE_OFF] + elif self.device_type == "HotWater": + self.modes = [STATE_AUTO, STATE_ON, STATE_OFF] + + self.session.entities.append(self) + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + def handle_update(self, updatesource): + """Handle the new update request.""" + if '{}.{}'.format(self.device_type, self.node_id) not in updatesource: + self.schedule_update_ha_state() + + @property + def name(self): + """Return the name of the Climate device.""" + friendly_name = "Climate Device" + if self.device_type == "Heating": + friendly_name = "Heating" + if self.node_name is not None: + friendly_name = '{} {}'.format(self.node_name, friendly_name) + elif self.device_type == "HotWater": + 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.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + if self.device_type == "Heating": + return self.session.heating.current_temperature(self.node_id) + + @property + def target_temperature(self): + """Return the target temperature.""" + if self.device_type == "Heating": + return self.session.heating.get_target_temperature(self.node_id) + + @property + def min_temp(self): + """Return minimum temperature.""" + if self.device_type == "Heating": + return self.session.heating.min_temperature(self.node_id) + + @property + def max_temp(self): + """Return the maximum temperature.""" + if self.device_type == "Heating": + return self.session.heating.max_temperature(self.node_id) + + @property + def operation_list(self): + """List of the operation modes.""" + return self.modes + + @property + def current_operation(self): + """Return current mode.""" + if self.device_type == "Heating": + currentmode = self.session.heating.get_mode(self.node_id) + elif self.device_type == "HotWater": + currentmode = self.session.hotwater.get_mode(self.node_id) + return HIVE_TO_HASS_STATE.get(currentmode) + + def set_operation_mode(self, operation_mode): + """Set new Heating mode.""" + new_mode = HASS_TO_HIVE_STATE.get(operation_mode) + if self.device_type == "Heating": + self.session.heating.set_mode(self.node_id, new_mode) + elif self.device_type == "HotWater": + self.session.hotwater.set_mode(self.node_id, new_mode) + + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + new_temperature = kwargs.get(ATTR_TEMPERATURE) + if new_temperature is not None: + if self.device_type == "Heating": + self.session.heating.set_target_temperature(self.node_id, + new_temperature) + + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) + + @property + def is_aux_heat_on(self): + """Return true if auxiliary heater is on.""" + boost_status = None + if self.device_type == "Heating": + boost_status = self.session.heating.get_boost(self.node_id) + elif self.device_type == "HotWater": + boost_status = self.session.hotwater.get_boost(self.node_id) + return boost_status == "ON" + + def turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + target_boost_time = 30 + if self.device_type == "Heating": + curtemp = self.session.heating.current_temperature(self.node_id) + curtemp = round(curtemp * 2) / 2 + target_boost_temperature = curtemp + 0.5 + self.session.heating.turn_boost_on(self.node_id, + target_boost_time, + target_boost_temperature) + elif self.device_type == "HotWater": + self.session.hotwater.turn_boost_on(self.node_id, + target_boost_time) + + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) + + def turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + if self.device_type == "Heating": + self.session.heating.turn_boost_off(self.node_id) + elif self.device_type == "HotWater": + self.session.hotwater.turn_boost_off(self.node_id) + + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) + + 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/homematic.py b/homeassistant/components/climate/homematic.py index ce6e9580e54fc..b8fb7a984fa39 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -5,9 +5,11 @@ https://home-assistant.io/components/climate.homematic/ """ import logging -from homeassistant.components.climate import ClimateDevice, STATE_AUTO -from homeassistant.components.homematic import HMDevice, ATTR_DISCOVER_DEVICES -from homeassistant.util.temperature import convert +from homeassistant.components.climate import ( + ClimateDevice, STATE_AUTO, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_OPERATION_MODE) +from homeassistant.components.homematic import ( + HMDevice, ATTR_DISCOVER_DEVICES, HM_ATTRIBUTE_SUPPORT) from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN, ATTR_TEMPERATURE DEPENDENCIES = ['homematic'] @@ -38,6 +40,9 @@ ] HM_CONTROL_MODE = 'CONTROL_MODE' +HM_IP_CONTROL_MODE = 'SET_POINT_MODE' + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE def setup_platform(hass, config, add_devices, discovery_info=None): @@ -56,6 +61,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class HMThermostat(HMDevice, ClimateDevice): """Representation of a Homematic thermostat.""" + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + @property def temperature_unit(self): """Return the unit of measurement that is used.""" @@ -67,11 +77,25 @@ def current_operation(self): if HM_CONTROL_MODE not in self._data: return None - # read state and search - for mode, state in HM_STATE_MAP.items(): - code = getattr(self._hmdevice, mode, 0) - if self._data.get('CONTROL_MODE') == code: - return state + set_point_mode = self._data.get('SET_POINT_MODE', -1) + control_mode = self._data.get('CONTROL_MODE', -1) + boost_mode = self._data.get('BOOST_MODE', False) + + # boost mode is active + if boost_mode: + return STATE_BOOST + + # HM ip etrv 2 uses the set_point_mode to say if its + # auto or manual + elif not set_point_mode == -1: + code = set_point_mode + # Other devices use the control_mode + else: + code = control_mode + + # get the name of the mode + name = HM_ATTRIBUTE_SUPPORT[HM_CONTROL_MODE][1][code] + return name.lower() @property def operation_list(self): @@ -117,23 +141,25 @@ def set_operation_mode(self, operation_mode): if state == operation_mode: code = getattr(self._hmdevice, mode, 0) self._hmdevice.MODE = code + return @property def min_temp(self): """Return the minimum temperature - 4.5 means off.""" - return convert(4.5, TEMP_CELSIUS, self.unit_of_measurement) + return 4.5 @property def max_temp(self): """Return the maximum temperature - 30.5 means on.""" - return convert(30.5, TEMP_CELSIUS, self.unit_of_measurement) + return 30.5 def _init_data_struct(self): """Generate a data dict (self._data) from the Homematic metadata.""" self._state = next(iter(self._hmdevice.WRITENODE.keys())) self._data[self._state] = STATE_UNKNOWN - if HM_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE: + if HM_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE or \ + HM_IP_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE: self._data[HM_CONTROL_MODE] = STATE_UNKNOWN for node in self._hmdevice.SENSORNODE.keys(): diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 0b2df903e172f..11a507aded2d0 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -11,16 +11,16 @@ import requests import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.climate import ( ClimateDevice, PLATFORM_SCHEMA, ATTR_FAN_MODE, ATTR_FAN_LIST, - ATTR_OPERATION_MODE, ATTR_OPERATION_LIST) + ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_AWAY_MODE, SUPPORT_OPERATION_MODE) from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, - ATTR_TEMPERATURE) -import homeassistant.helpers.config_validation as cv + ATTR_TEMPERATURE, CONF_REGION) -REQUIREMENTS = ['evohomeclient==0.2.5', - 'somecomfort==0.4.1'] +REQUIREMENTS = ['evohomeclient==0.2.5', 'somecomfort==0.5.2'] _LOGGER = logging.getLogger(__name__) @@ -31,7 +31,6 @@ CONF_AWAY_TEMPERATURE = 'away_temperature' CONF_COOL_AWAY_TEMPERATURE = 'away_cool_temperature' CONF_HEAT_AWAY_TEMPERATURE = 'away_heat_temperature' -CONF_REGION = 'region' DEFAULT_AWAY_TEMPERATURE = 16 DEFAULT_COOL_AWAY_TEMPERATURE = 30 @@ -128,6 +127,14 @@ def __init__(self, client, zone_id, master, away_temp): self._away_temp = away_temp self._away = False + @property + def supported_features(self): + """Return the list of supported features.""" + supported = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE) + if hasattr(self.client, ATTR_SYSTEM_MODE): + supported |= SUPPORT_OPERATION_MODE + return supported + @property def name(self): """Return the name of the honeywell, if any.""" @@ -236,6 +243,14 @@ def __init__(self, client, device, cool_away_temp, self._username = username self._password = password + @property + def supported_features(self): + """Return the list of supported features.""" + supported = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE) + if hasattr(self._device, ATTR_SYSTEM_MODE): + supported |= SUPPORT_OPERATION_MODE + return supported + @property def is_fan_on(self): """Return true if fan is on.""" diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 688ded5e7c4bb..5ce6cc2fa7af0 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -4,16 +4,22 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.knx/ """ -import asyncio + import voluptuous as vol -from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES -from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice -from homeassistant.const import CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.components.climate import ( + PLATFORM_SCHEMA, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, + ClimateDevice) +from homeassistant.components.knx import ATTR_DISCOVER_DEVICES, DATA_KNX +from homeassistant.const import ATTR_TEMPERATURE, CONF_NAME, TEMP_CELSIUS from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -CONF_SETPOINT_ADDRESS = 'setpoint_address' +CONF_SETPOINT_SHIFT_ADDRESS = 'setpoint_shift_address' +CONF_SETPOINT_SHIFT_STATE_ADDRESS = 'setpoint_shift_state_address' +CONF_SETPOINT_SHIFT_STEP = 'setpoint_shift_step' +CONF_SETPOINT_SHIFT_MAX = 'setpoint_shift_max' +CONF_SETPOINT_SHIFT_MIN = 'setpoint_shift_min' CONF_TEMPERATURE_ADDRESS = 'temperature_address' CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' @@ -26,13 +32,24 @@ CONF_OPERATION_MODE_COMFORT_ADDRESS = 'operation_mode_comfort_address' DEFAULT_NAME = 'KNX Climate' +DEFAULT_SETPOINT_SHIFT_STEP = 0.5 +DEFAULT_SETPOINT_SHIFT_MAX = 6 +DEFAULT_SETPOINT_SHIFT_MIN = -6 DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_SETPOINT_ADDRESS): cv.string, vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, + vol.Optional(CONF_SETPOINT_SHIFT_ADDRESS): cv.string, + vol.Optional(CONF_SETPOINT_SHIFT_STATE_ADDRESS): cv.string, + vol.Optional(CONF_SETPOINT_SHIFT_STEP, + default=DEFAULT_SETPOINT_SHIFT_STEP): vol.All( + float, vol.Range(min=0, max=2)), + vol.Optional(CONF_SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX): + vol.All(int, vol.Range(min=0, max=32)), + vol.Optional(CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN): + vol.All(int, vol.Range(min=-32, max=0)), vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string, @@ -43,47 +60,43 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, add_devices, - discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up climate(s) for KNX platform.""" - if DATA_KNX not in hass.data \ - or not hass.data[DATA_KNX].initialized: - return False - if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) - - return True + async_add_devices_config(hass, config, async_add_devices) @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): - """Set up climates for KNX platform configured within plattform.""" +def async_add_devices_discovery(hass, discovery_info, async_add_devices): + """Set up climates for KNX platform configured within platform.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXClimate(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): - """Set up climate for KNX platform configured within plattform.""" +def async_add_devices_config(hass, config, async_add_devices): + """Set up climate for KNX platform configured within platform.""" import xknx + climate = xknx.devices.Climate( hass.data[DATA_KNX].xknx, name=config.get(CONF_NAME), - group_address_temperature=config.get( - CONF_TEMPERATURE_ADDRESS), + group_address_temperature=config.get(CONF_TEMPERATURE_ADDRESS), group_address_target_temperature=config.get( CONF_TARGET_TEMPERATURE_ADDRESS), - group_address_setpoint=config.get( - CONF_SETPOINT_ADDRESS), - group_address_operation_mode=config.get( - CONF_OPERATION_MODE_ADDRESS), + group_address_setpoint_shift=config.get(CONF_SETPOINT_SHIFT_ADDRESS), + group_address_setpoint_shift_state=config.get( + CONF_SETPOINT_SHIFT_STATE_ADDRESS), + setpoint_shift_step=config.get(CONF_SETPOINT_SHIFT_STEP), + setpoint_shift_max=config.get(CONF_SETPOINT_SHIFT_MAX), + setpoint_shift_min=config.get(CONF_SETPOINT_SHIFT_MIN), + group_address_operation_mode=config.get(CONF_OPERATION_MODE_ADDRESS), group_address_operation_mode_state=config.get( CONF_OPERATION_MODE_STATE_ADDRESS), group_address_controller_status=config.get( @@ -97,29 +110,34 @@ def async_add_devices_config(hass, config, add_devices): group_address_operation_mode_comfort=config.get( CONF_OPERATION_MODE_COMFORT_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(climate) - add_devices([KNXClimate(hass, climate)]) + async_add_devices([KNXClimate(hass, climate)]) class KNXClimate(ClimateDevice): - """Representation of a KNX climate.""" + """Representation of a KNX climate device.""" def __init__(self, hass, device): - """Initialization of KNXClimate.""" + """Initialize of a KNX climate device.""" self.device = device self.hass = hass self.async_register_callbacks() self._unit_of_measurement = TEMP_CELSIUS - self._away = False # not yet supported - self._is_fan_on = False # not yet supported + + @property + def supported_features(self): + """Return the list of supported features.""" + support = SUPPORT_TARGET_TEMPERATURE + if self.device.supports_operation_mode: + support |= SUPPORT_OPERATION_MODE + return support def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): - """Callback after device was updated.""" + async def after_update_callback(device): + """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property @@ -127,6 +145,11 @@ def name(self): """Return the name of the KNX device.""" return self.device.name + @property + def available(self): + """Return True if entity is available.""" + return self.hass.data[DATA_KNX].connected + @property def should_poll(self): """No polling needed within KNX.""" @@ -140,23 +163,35 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - return self.device.temperature + return self.device.temperature.value + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return self.device.setpoint_shift_step @property def target_temperature(self): """Return the temperature we try to reach.""" - if self.device.supports_target_temperature: - return self.device.target_temperature - return None + return self.device.target_temperature.value + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self.device.target_temperature_min + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.device.target_temperature_max - @asyncio.coroutine - def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - if self.device.supports_target_temperature: - yield from self.device.set_target_temperature(temperature) + await self.device.set_target_temperature(temperature) + await self.async_update_ha_state() @property def current_operation(self): @@ -172,10 +207,9 @@ def operation_list(self): operation_mode in self.device.get_supported_operation_modes()] - @asyncio.coroutine - def async_set_operation_mode(self, operation_mode): + async def async_set_operation_mode(self, operation_mode): """Set operation mode.""" if self.device.supports_operation_mode: from xknx.knx import HVACOperationMode knx_operation_mode = HVACOperationMode(operation_mode) - yield from self.device.set_operation_mode(knx_operation_mode) + await self.device.set_operation_mode(knx_operation_mode) diff --git a/homeassistant/components/climate/maxcube.py b/homeassistant/components/climate/maxcube.py index 271616daf8b04..712ebb4f4ce6e 100644 --- a/homeassistant/components/climate/maxcube.py +++ b/homeassistant/components/climate/maxcube.py @@ -7,8 +7,10 @@ import socket import logging -from homeassistant.components.climate import ClimateDevice, STATE_AUTO -from homeassistant.components.maxcube import MAXCUBE_HANDLE +from homeassistant.components.climate import ( + ClimateDevice, STATE_AUTO, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_OPERATION_MODE) +from homeassistant.components.maxcube import DATA_KEY from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE _LOGGER = logging.getLogger(__name__) @@ -17,19 +19,21 @@ STATE_BOOST = 'boost' STATE_VACATION = 'vacation' +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) @@ -38,14 +42,19 @@ 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): + """Return the list of supported features.""" + return SUPPORT_FLAGS @property def should_poll(self): diff --git a/homeassistant/components/climate/melissa.py b/homeassistant/components/climate/melissa.py new file mode 100644 index 0000000000000..9c005b62dccf7 --- /dev/null +++ b/homeassistant/components/climate/melissa.py @@ -0,0 +1,252 @@ +""" +Support for Melissa Climate A/C. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.melissa/ +""" +import logging + +from homeassistant.components.climate import ( + ClimateDevice, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_ON_OFF, STATE_AUTO, STATE_HEAT, STATE_COOL, STATE_DRY, + STATE_FAN_ONLY, SUPPORT_FAN_MODE +) +from homeassistant.components.fan import SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH +from homeassistant.components.melissa import DATA_MELISSA +from homeassistant.const import ( + TEMP_CELSIUS, STATE_ON, STATE_OFF, STATE_IDLE, ATTR_TEMPERATURE, + PRECISION_WHOLE +) + +DEPENDENCIES = ['melissa'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = (SUPPORT_FAN_MODE | SUPPORT_OPERATION_MODE | + SUPPORT_ON_OFF | SUPPORT_TARGET_TEMPERATURE) + +OP_MODES = [ + STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT +] + +FAN_MODES = [ + STATE_AUTO, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM +] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Iterate through and add all Melissa devices.""" + api = hass.data[DATA_MELISSA] + devices = api.fetch_devices().values() + + all_devices = [] + + for device in devices: + if device['type'] == 'melissa': + all_devices.append(MelissaClimate( + api, device['serial_number'], device)) + + add_devices(all_devices) + + +class MelissaClimate(ClimateDevice): + """Representation of a Melissa Climate device.""" + + def __init__(self, api, serial_number, init_data): + """Initialize the climate device.""" + self._name = init_data['name'] + self._api = api + self._serial_number = serial_number + self._data = init_data['controller_log'] + self._state = None + self._cur_settings = None + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._name + + @property + def is_on(self): + """Return current state.""" + if self._cur_settings is not None: + return self._cur_settings[self._api.STATE] in ( + self._api.STATE_ON, self._api.STATE_IDLE) + return None + + @property + def current_fan_mode(self): + """Return the current fan mode.""" + if self._cur_settings is not None: + return self.melissa_fan_to_hass( + self._cur_settings[self._api.FAN]) + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._data: + return self._data[self._api.TEMP] + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return PRECISION_WHOLE + + @property + def current_operation(self): + """Return the current operation mode.""" + if self._cur_settings is not None: + return self.melissa_op_to_hass( + self._cur_settings[self._api.MODE]) + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return OP_MODES + + @property + def fan_list(self): + """List of available fan modes.""" + return FAN_MODES + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self._cur_settings is not None: + return self._cur_settings[self._api.TEMP] + + @property + def state(self): + """Return current state.""" + if self._cur_settings is not None: + return self.melissa_state_to_hass( + self._cur_settings[self._api.STATE]) + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def min_temp(self): + """Return the minimum supported temperature for the thermostat.""" + return 16 + + @property + def max_temp(self): + """Return the maximum supported temperature for the thermostat.""" + return 30 + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temp = kwargs.get(ATTR_TEMPERATURE) + self.send({self._api.TEMP: temp}) + + def set_fan_mode(self, fan_mode): + """Set fan mode.""" + melissa_fan_mode = self.hass_fan_to_melissa(fan_mode) + self.send({self._api.FAN: melissa_fan_mode}) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + mode = self.hass_mode_to_melissa(operation_mode) + self.send({self._api.MODE: mode}) + + def turn_on(self): + """Turn on device.""" + self.send({self._api.STATE: self._api.STATE_ON}) + + def turn_off(self): + """Turn off device.""" + self.send({self._api.STATE: self._api.STATE_OFF}) + + def send(self, value): + """Sending action to service.""" + try: + old_value = self._cur_settings.copy() + self._cur_settings.update(value) + except AttributeError: + old_value = None + if not self._api.send(self._serial_number, self._cur_settings): + self._cur_settings = old_value + return False + return True + + def update(self): + """Get latest data from Melissa.""" + try: + self._data = self._api.status(cached=True)[self._serial_number] + self._cur_settings = self._api.cur_settings( + self._serial_number + )['controller']['_relation']['command_log'] + except KeyError: + _LOGGER.warning( + 'Unable to update entity %s', self.entity_id) + + def melissa_state_to_hass(self, state): + """Translate Melissa states to hass states.""" + if state == self._api.STATE_ON: + return STATE_ON + elif state == self._api.STATE_OFF: + return STATE_OFF + elif state == self._api.STATE_IDLE: + return STATE_IDLE + return None + + def melissa_op_to_hass(self, mode): + """Translate Melissa modes to hass states.""" + if mode == self._api.MODE_HEAT: + return STATE_HEAT + elif mode == self._api.MODE_COOL: + return STATE_COOL + elif mode == self._api.MODE_DRY: + return STATE_DRY + elif mode == self._api.MODE_FAN: + return STATE_FAN_ONLY + _LOGGER.warning( + "Operation mode %s could not be mapped to hass", mode) + return None + + def melissa_fan_to_hass(self, fan): + """Translate Melissa fan modes to hass modes.""" + if fan == self._api.FAN_AUTO: + return STATE_AUTO + elif fan == self._api.FAN_LOW: + return SPEED_LOW + elif fan == self._api.FAN_MEDIUM: + return SPEED_MEDIUM + elif fan == self._api.FAN_HIGH: + return SPEED_HIGH + _LOGGER.warning("Fan mode %s could not be mapped to hass", fan) + return None + + def hass_mode_to_melissa(self, mode): + """Translate hass states to melissa modes.""" + if mode == STATE_HEAT: + return self._api.MODE_HEAT + elif mode == STATE_COOL: + return self._api.MODE_COOL + elif mode == STATE_DRY: + return self._api.MODE_DRY + elif mode == STATE_FAN_ONLY: + return self._api.MODE_FAN + else: + _LOGGER.warning("Melissa have no setting for %s mode", mode) + + def hass_fan_to_melissa(self, fan): + """Translate hass fan modes to melissa modes.""" + if fan == STATE_AUTO: + return self._api.FAN_AUTO + elif fan == SPEED_LOW: + return self._api.FAN_LOW + elif fan == SPEED_MEDIUM: + return self._api.FAN_MEDIUM + elif fan == SPEED_HIGH: + return self._api.FAN_HIGH + else: + _LOGGER.warning("Melissa have no setting for %s fan mode", fan) diff --git a/homeassistant/components/climate/modbus.py b/homeassistant/components/climate/modbus.py new file mode 100644 index 0000000000000..7d392e5a40f6a --- /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/mqtt.py b/homeassistant/components/climate/mqtt.py new file mode 100644 index 0000000000000..1d98a5733f705 --- /dev/null +++ b/homeassistant/components/climate/mqtt.py @@ -0,0 +1,621 @@ +""" +Support for MQTT climate devices. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.mqtt/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.core import callback +import homeassistant.components.mqtt as mqtt + +from homeassistant.components.climate import ( + STATE_HEAT, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, ClimateDevice, + PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, STATE_AUTO, + ATTR_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, + SUPPORT_SWING_MODE, SUPPORT_FAN_MODE, SUPPORT_AWAY_MODE, SUPPORT_HOLD_MODE, + SUPPORT_AUX_HEAT) +from homeassistant.const import ( + STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, CONF_VALUE_TEMPLATE) +from homeassistant.components.mqtt import ( + CONF_AVAILABILITY_TOPIC, CONF_QOS, CONF_RETAIN, CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, MQTT_BASE_PLATFORM_SCHEMA, MqttAvailability) +import homeassistant.helpers.config_validation as cv +from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, + SPEED_HIGH) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mqtt'] + +DEFAULT_NAME = 'MQTT HVAC' + +CONF_POWER_COMMAND_TOPIC = 'power_command_topic' +CONF_POWER_STATE_TOPIC = 'power_state_topic' +CONF_POWER_STATE_TEMPLATE = 'power_state_template' +CONF_MODE_COMMAND_TOPIC = 'mode_command_topic' +CONF_MODE_STATE_TOPIC = 'mode_state_topic' +CONF_MODE_STATE_TEMPLATE = 'mode_state_template' +CONF_TEMPERATURE_COMMAND_TOPIC = 'temperature_command_topic' +CONF_TEMPERATURE_STATE_TOPIC = 'temperature_state_topic' +CONF_TEMPERATURE_STATE_TEMPLATE = 'temperature_state_template' +CONF_FAN_MODE_COMMAND_TOPIC = 'fan_mode_command_topic' +CONF_FAN_MODE_STATE_TOPIC = 'fan_mode_state_topic' +CONF_FAN_MODE_STATE_TEMPLATE = 'fan_mode_state_template' +CONF_SWING_MODE_COMMAND_TOPIC = 'swing_mode_command_topic' +CONF_SWING_MODE_STATE_TOPIC = 'swing_mode_state_topic' +CONF_SWING_MODE_STATE_TEMPLATE = 'swing_mode_state_template' +CONF_AWAY_MODE_COMMAND_TOPIC = 'away_mode_command_topic' +CONF_AWAY_MODE_STATE_TOPIC = 'away_mode_state_topic' +CONF_AWAY_MODE_STATE_TEMPLATE = 'away_mode_state_template' +CONF_HOLD_COMMAND_TOPIC = 'hold_command_topic' +CONF_HOLD_STATE_TOPIC = 'hold_state_topic' +CONF_HOLD_STATE_TEMPLATE = 'hold_state_template' +CONF_AUX_COMMAND_TOPIC = 'aux_command_topic' +CONF_AUX_STATE_TOPIC = 'aux_state_topic' +CONF_AUX_STATE_TEMPLATE = 'aux_state_template' + +CONF_CURRENT_TEMPERATURE_TEMPLATE = 'current_temperature_template' +CONF_CURRENT_TEMPERATURE_TOPIC = 'current_temperature_topic' + +CONF_PAYLOAD_ON = 'payload_on' +CONF_PAYLOAD_OFF = 'payload_off' + +CONF_FAN_MODE_LIST = 'fan_modes' +CONF_MODE_LIST = 'modes' +CONF_SWING_MODE_LIST = 'swing_modes' +CONF_INITIAL = 'initial' +CONF_SEND_IF_OFF = 'send_if_off' + +SCHEMA_BASE = CLIMATE_PLATFORM_SCHEMA.extend(MQTT_BASE_PLATFORM_SCHEMA.schema) +PLATFORM_SCHEMA = SCHEMA_BASE.extend({ + vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_TEMPERATURE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_FAN_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_AWAY_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_HOLD_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_AUX_COMMAND_TOPIC): mqtt.valid_publish_topic, + + vol.Optional(CONF_POWER_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_TEMPERATURE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_FAN_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_SWING_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_AWAY_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_HOLD_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_AUX_STATE_TOPIC): mqtt.valid_subscribe_topic, + + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_POWER_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_TEMPERATURE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_FAN_MODE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_MODE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_AWAY_MODE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_HOLD_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_AUX_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_CURRENT_TEMPERATURE_TEMPLATE): cv.template, + + vol.Optional(CONF_CURRENT_TEMPERATURE_TOPIC): + mqtt.valid_subscribe_topic, + vol.Optional(CONF_FAN_MODE_LIST, + default=[STATE_AUTO, SPEED_LOW, + SPEED_MEDIUM, SPEED_HIGH]): cv.ensure_list, + vol.Optional(CONF_SWING_MODE_LIST, + default=[STATE_ON, STATE_OFF]): cv.ensure_list, + vol.Optional(CONF_MODE_LIST, + default=[STATE_AUTO, STATE_OFF, STATE_COOL, STATE_HEAT, + STATE_DRY, STATE_FAN_ONLY]): cv.ensure_list, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_INITIAL, default=21): cv.positive_int, + vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean, + vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the MQTT climate devices.""" + template_keys = ( + CONF_POWER_STATE_TEMPLATE, + CONF_MODE_STATE_TEMPLATE, + CONF_TEMPERATURE_STATE_TEMPLATE, + CONF_FAN_MODE_STATE_TEMPLATE, + CONF_SWING_MODE_STATE_TEMPLATE, + CONF_AWAY_MODE_STATE_TEMPLATE, + CONF_HOLD_STATE_TEMPLATE, + CONF_AUX_STATE_TEMPLATE, + CONF_CURRENT_TEMPERATURE_TEMPLATE + ) + value_templates = {} + if CONF_VALUE_TEMPLATE in config: + value_template = config.get(CONF_VALUE_TEMPLATE) + value_template.hass = hass + value_templates = {key: value_template for key in template_keys} + for key in template_keys & config.keys(): + value_templates[key] = config.get(key) + value_templates[key].hass = hass + + async_add_devices([ + MqttClimate( + hass, + config.get(CONF_NAME), + { + key: config.get(key) for key in ( + CONF_POWER_COMMAND_TOPIC, + CONF_MODE_COMMAND_TOPIC, + CONF_TEMPERATURE_COMMAND_TOPIC, + CONF_FAN_MODE_COMMAND_TOPIC, + CONF_SWING_MODE_COMMAND_TOPIC, + CONF_AWAY_MODE_COMMAND_TOPIC, + CONF_HOLD_COMMAND_TOPIC, + CONF_AUX_COMMAND_TOPIC, + CONF_POWER_STATE_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_TEMPERATURE_STATE_TOPIC, + CONF_FAN_MODE_STATE_TOPIC, + CONF_SWING_MODE_STATE_TOPIC, + CONF_AWAY_MODE_STATE_TOPIC, + CONF_HOLD_STATE_TOPIC, + CONF_AUX_STATE_TOPIC, + CONF_CURRENT_TEMPERATURE_TOPIC + ) + }, + value_templates, + config.get(CONF_QOS), + config.get(CONF_RETAIN), + config.get(CONF_MODE_LIST), + config.get(CONF_FAN_MODE_LIST), + config.get(CONF_SWING_MODE_LIST), + config.get(CONF_INITIAL), + False, None, SPEED_LOW, + STATE_OFF, STATE_OFF, False, + config.get(CONF_SEND_IF_OFF), + config.get(CONF_PAYLOAD_ON), + config.get(CONF_PAYLOAD_OFF), + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE)) + ]) + + +class MqttClimate(MqttAvailability, ClimateDevice): + """Representation of a demo climate device.""" + + def __init__(self, hass, name, topic, value_templates, qos, retain, + mode_list, fan_mode_list, swing_mode_list, + target_temperature, away, hold, current_fan_mode, + current_swing_mode, current_operation, aux, send_if_off, + payload_on, payload_off, availability_topic, + payload_available, payload_not_available): + """Initialize the climate device.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) + self.hass = hass + self._name = name + self._topic = topic + self._value_templates = value_templates + self._qos = qos + self._retain = retain + self._target_temperature = target_temperature + self._unit_of_measurement = hass.config.units.temperature_unit + self._away = away + self._hold = hold + self._current_temperature = None + self._current_fan_mode = current_fan_mode + self._current_operation = current_operation + self._aux = aux + self._current_swing_mode = current_swing_mode + self._fan_list = fan_mode_list + self._operation_list = mode_list + self._swing_list = swing_mode_list + self._target_temperature_step = 1 + self._send_if_off = send_if_off + self._payload_on = payload_on + self._payload_off = payload_off + + @asyncio.coroutine + def async_added_to_hass(self): + """Handle being added to home assistant.""" + yield from super().async_added_to_hass() + + @callback + def handle_current_temp_received(topic, payload, qos): + """Handle current temperature coming via MQTT.""" + if CONF_CURRENT_TEMPERATURE_TEMPLATE in self._value_templates: + payload =\ + self._value_templates[CONF_CURRENT_TEMPERATURE_TEMPLATE].\ + async_render_with_possible_json_value(payload) + + try: + self._current_temperature = float(payload) + self.async_schedule_update_ha_state() + except ValueError: + _LOGGER.error("Could not parse temperature from %s", payload) + + if self._topic[CONF_CURRENT_TEMPERATURE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_CURRENT_TEMPERATURE_TOPIC], + handle_current_temp_received, self._qos) + + @callback + def handle_mode_received(topic, payload, qos): + """Handle receiving mode via MQTT.""" + if CONF_MODE_STATE_TEMPLATE in self._value_templates: + payload = self._value_templates[CONF_MODE_STATE_TEMPLATE].\ + async_render_with_possible_json_value(payload) + + if payload not in self._operation_list: + _LOGGER.error("Invalid mode: %s", payload) + else: + self._current_operation = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_MODE_STATE_TOPIC], + handle_mode_received, self._qos) + + @callback + def handle_temperature_received(topic, payload, qos): + """Handle target temperature coming via MQTT.""" + if CONF_TEMPERATURE_STATE_TEMPLATE in self._value_templates: + payload = \ + self._value_templates[CONF_TEMPERATURE_STATE_TEMPLATE].\ + async_render_with_possible_json_value(payload) + + try: + self._target_temperature = float(payload) + self.async_schedule_update_ha_state() + except ValueError: + _LOGGER.error("Could not parse temperature from %s", payload) + + if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_TEMPERATURE_STATE_TOPIC], + handle_temperature_received, self._qos) + + @callback + def handle_fan_mode_received(topic, payload, qos): + """Handle receiving fan mode via MQTT.""" + if CONF_FAN_MODE_STATE_TEMPLATE in self._value_templates: + payload = \ + self._value_templates[CONF_FAN_MODE_STATE_TEMPLATE].\ + async_render_with_possible_json_value(payload) + + if payload not in self._fan_list: + _LOGGER.error("Invalid fan mode: %s", payload) + else: + self._current_fan_mode = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_FAN_MODE_STATE_TOPIC], + handle_fan_mode_received, self._qos) + + @callback + def handle_swing_mode_received(topic, payload, qos): + """Handle receiving swing mode via MQTT.""" + if CONF_SWING_MODE_STATE_TEMPLATE in self._value_templates: + payload = \ + self._value_templates[CONF_SWING_MODE_STATE_TEMPLATE].\ + async_render_with_possible_json_value(payload) + + if payload not in self._swing_list: + _LOGGER.error("Invalid swing mode: %s", payload) + else: + self._current_swing_mode = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_SWING_MODE_STATE_TOPIC], + handle_swing_mode_received, self._qos) + + @callback + def handle_away_mode_received(topic, payload, qos): + """Handle receiving away mode via MQTT.""" + if CONF_AWAY_MODE_STATE_TEMPLATE in self._value_templates: + payload = \ + self._value_templates[CONF_AWAY_MODE_STATE_TEMPLATE].\ + async_render_with_possible_json_value(payload) + if payload == "True": + payload = self._payload_on + elif payload == "False": + payload = self._payload_off + + if payload == self._payload_on: + self._away = True + elif payload == self._payload_off: + self._away = False + else: + _LOGGER.error("Invalid away mode: %s", payload) + + self.async_schedule_update_ha_state() + + if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_AWAY_MODE_STATE_TOPIC], + handle_away_mode_received, self._qos) + + @callback + def handle_aux_mode_received(topic, payload, qos): + """Handle receiving aux mode via MQTT.""" + if CONF_AUX_STATE_TEMPLATE in self._value_templates: + payload = self._value_templates[CONF_AUX_STATE_TEMPLATE].\ + async_render_with_possible_json_value(payload) + if payload == "True": + payload = self._payload_on + elif payload == "False": + payload = self._payload_off + + if payload == self._payload_on: + self._aux = True + elif payload == self._payload_off: + self._aux = False + else: + _LOGGER.error("Invalid aux mode: %s", payload) + + self.async_schedule_update_ha_state() + + if self._topic[CONF_AUX_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_AUX_STATE_TOPIC], + handle_aux_mode_received, self._qos) + + @callback + def handle_hold_mode_received(topic, payload, qos): + """Handle receiving hold mode via MQTT.""" + if CONF_HOLD_STATE_TEMPLATE in self._value_templates: + payload = self._value_templates[CONF_HOLD_STATE_TEMPLATE].\ + async_render_with_possible_json_value(payload) + + self._hold = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_HOLD_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_HOLD_STATE_TOPIC], + handle_hold_mode_received, self._qos) + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @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 + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_operation + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._operation_list + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return self._target_temperature_step + + @property + def is_away_mode_on(self): + """Return if away mode is on.""" + return self._away + + @property + def current_hold_mode(self): + """Return hold mode setting.""" + return self._hold + + @property + def is_aux_heat_on(self): + """Return true if away mode is on.""" + return self._aux + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self._current_fan_mode + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return self._fan_list + + @asyncio.coroutine + def async_set_temperature(self, **kwargs): + """Set new target temperatures.""" + if kwargs.get(ATTR_OPERATION_MODE) is not None: + operation_mode = kwargs.get(ATTR_OPERATION_MODE) + yield from self.async_set_operation_mode(operation_mode) + + if kwargs.get(ATTR_TEMPERATURE) is not None: + if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is None: + # optimistic mode + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + + if self._send_if_off or self._current_operation != STATE_OFF: + mqtt.async_publish( + self.hass, self._topic[CONF_TEMPERATURE_COMMAND_TOPIC], + kwargs.get(ATTR_TEMPERATURE), self._qos, self._retain) + + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_swing_mode(self, swing_mode): + """Set new swing mode.""" + if self._send_if_off or self._current_operation != STATE_OFF: + mqtt.async_publish( + self.hass, self._topic[CONF_SWING_MODE_COMMAND_TOPIC], + swing_mode, self._qos, self._retain) + + if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: + self._current_swing_mode = swing_mode + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_fan_mode(self, fan_mode): + """Set new target temperature.""" + if self._send_if_off or self._current_operation != STATE_OFF: + mqtt.async_publish( + self.hass, self._topic[CONF_FAN_MODE_COMMAND_TOPIC], + fan_mode, self._qos, self._retain) + + if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: + self._current_fan_mode = fan_mode + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_operation_mode(self, operation_mode) -> None: + """Set new operation mode.""" + if self._topic[CONF_POWER_COMMAND_TOPIC] is not None: + if (self._current_operation == STATE_OFF and + operation_mode != STATE_OFF): + mqtt.async_publish( + self.hass, self._topic[CONF_POWER_COMMAND_TOPIC], + self._payload_on, self._qos, self._retain) + elif (self._current_operation != STATE_OFF and + operation_mode == STATE_OFF): + mqtt.async_publish( + self.hass, self._topic[CONF_POWER_COMMAND_TOPIC], + self._payload_off, self._qos, self._retain) + + if self._topic[CONF_MODE_COMMAND_TOPIC] is not None: + mqtt.async_publish( + self.hass, self._topic[CONF_MODE_COMMAND_TOPIC], + operation_mode, self._qos, self._retain) + + if self._topic[CONF_MODE_STATE_TOPIC] is None: + self._current_operation = operation_mode + self.async_schedule_update_ha_state() + + @property + def current_swing_mode(self): + """Return the swing setting.""" + return self._current_swing_mode + + @property + def swing_list(self): + """List of available swing modes.""" + return self._swing_list + + @asyncio.coroutine + def async_turn_away_mode_on(self): + """Turn away mode on.""" + if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, + self._topic[CONF_AWAY_MODE_COMMAND_TOPIC], + self._payload_on, self._qos, self._retain) + + if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: + self._away = True + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_away_mode_off(self): + """Turn away mode off.""" + if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, + self._topic[CONF_AWAY_MODE_COMMAND_TOPIC], + self._payload_off, self._qos, self._retain) + + if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: + self._away = False + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_hold_mode(self, hold_mode): + """Update hold mode on.""" + if self._topic[CONF_HOLD_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, + self._topic[CONF_HOLD_COMMAND_TOPIC], + hold_mode, self._qos, self._retain) + + if self._topic[CONF_HOLD_STATE_TOPIC] is None: + self._hold = hold_mode + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_aux_heat_on(self): + """Turn auxiliary heater on.""" + if self._topic[CONF_AUX_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC], + self._payload_on, self._qos, self._retain) + + if self._topic[CONF_AUX_STATE_TOPIC] is None: + self._aux = True + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_aux_heat_off(self): + """Turn auxiliary heater off.""" + if self._topic[CONF_AUX_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC], + self._payload_off, self._qos, self._retain) + + if self._topic[CONF_AUX_STATE_TOPIC] is None: + self._aux = False + self.async_schedule_update_ha_state() + + @property + def supported_features(self): + """Return the list of supported features.""" + support = 0 + + if (self._topic[CONF_TEMPERATURE_STATE_TOPIC] is not None) or \ + (self._topic[CONF_TEMPERATURE_COMMAND_TOPIC] is not None): + support |= SUPPORT_TARGET_TEMPERATURE + + if (self._topic[CONF_MODE_COMMAND_TOPIC] is not None) or \ + (self._topic[CONF_MODE_STATE_TOPIC] is not None): + support |= SUPPORT_OPERATION_MODE + + if (self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None) or \ + (self._topic[CONF_FAN_MODE_COMMAND_TOPIC] is not None): + support |= SUPPORT_FAN_MODE + + if (self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None) or \ + (self._topic[CONF_SWING_MODE_COMMAND_TOPIC] is not None): + support |= SUPPORT_SWING_MODE + + if (self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None) or \ + (self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None): + support |= SUPPORT_AWAY_MODE + + if (self._topic[CONF_HOLD_STATE_TOPIC] is not None) or \ + (self._topic[CONF_HOLD_COMMAND_TOPIC] is not None): + support |= SUPPORT_HOLD_MODE + + if (self._topic[CONF_AUX_STATE_TOPIC] is not None) or \ + (self._topic[CONF_AUX_COMMAND_TOPIC] is not None): + support |= SUPPORT_AUX_HEAT + + return support diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py old mode 100755 new mode 100644 index d4316c2cfbaf1..9fab56c61ac56 --- a/homeassistant/components/climate/mysensors.py +++ b/homeassistant/components/climate/mysensors.py @@ -7,7 +7,10 @@ from homeassistant.components import mysensors from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN, STATE_AUTO, - STATE_COOL, STATE_HEAT, STATE_OFF, ClimateDevice) + STATE_COOL, STATE_HEAT, STATE_OFF, SUPPORT_FAN_MODE, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW, + ClimateDevice) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT DICT_HA_TO_MYS = { @@ -23,16 +26,27 @@ 'Off': STATE_OFF, } +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_HIGH | + SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_FAN_MODE | + SUPPORT_OPERATION_MODE) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup 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): """Representation of a MySensors HVAC.""" + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + @property def assumed_state(self): """Return True if unable to access real state of entity.""" @@ -41,8 +55,7 @@ def assumed_state(self): @property def temperature_unit(self): """Return the unit of measurement.""" - return (TEMP_CELSIUS - if self.gateway.metric else TEMP_FAHRENHEIT) + return TEMP_CELSIUS if self.gateway.metric else TEMP_FAHRENHEIT @property def current_temperature(self): @@ -102,7 +115,7 @@ def fan_list(self): """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) @@ -128,32 +141,32 @@ def set_temperature(self, **kwargs): self.gateway.set_child_value( self.node_id, self.child_id, value_type, value) if self.gateway.optimistic: - # optimistically assume that device has changed state + # 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): + async def async_set_fan_mode(self, fan_mode): """Set new target temperature.""" set_req = self.gateway.const.SetReq self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan) + self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode) if self.gateway.optimistic: - # optimistically assume that device has changed state - self._values[set_req.V_HVAC_SPEED] = fan - self.schedule_update_ha_state() + # Optimistically assume that device has changed state + self._values[set_req.V_HVAC_SPEED] = fan_mode + 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, DICT_HA_TO_MYS[operation_mode]) if self.gateway.optimistic: - # optimistically assume that device has changed state + # 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 ac4f64f4ec833..0a5344fdf9899 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -10,9 +10,11 @@ from homeassistant.components.nest import DATA_NEST from homeassistant.components.climate import ( - STATE_AUTO, STATE_COOL, STATE_HEAT, ClimateDevice, + STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_ECO, ClimateDevice, PLATFORM_SCHEMA, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE) + ATTR_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW, + SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE) from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF, STATE_UNKNOWN) @@ -25,8 +27,7 @@ vol.All(vol.Coerce(int), vol.Range(min=1)), }) -STATE_ECO = 'eco' -STATE_HEAT_COOL = 'heat-cool' +NEST_MODE_HEAT_COOL = 'heat-cool' def setup_platform(hass, config, add_devices, discovery_info=None): @@ -53,6 +54,10 @@ def __init__(self, structure, device, temp_unit): self.device = device self._fan_list = [STATE_ON, STATE_AUTO] + # Set the default supported features + self._support_flags = (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE) + # Not all nest devices support cooling and heating remove unused self._operation_list = [STATE_OFF] @@ -65,11 +70,16 @@ def __init__(self, structure, device, temp_unit): if self.device.can_heat and self.device.can_cool: self._operation_list.append(STATE_AUTO) + self._support_flags = (self._support_flags | + SUPPORT_TARGET_TEMPERATURE_HIGH | + SUPPORT_TARGET_TEMPERATURE_LOW) self._operation_list.append(STATE_ECO) # feature of device self._has_fan = self.device.has_fan + if self._has_fan: + self._support_flags = (self._support_flags | SUPPORT_FAN_MODE) # data attributes self._away = None @@ -87,6 +97,16 @@ def __init__(self, structure, device, temp_unit): self._min_temperature = None self._max_temperature = None + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + @property + def unique_id(self): + """Unique ID for this device.""" + return self.device.serial + @property def name(self): """Return the name of the nest, if any.""" @@ -107,14 +127,14 @@ def current_operation(self): """Return current operation ie. heat, cool, idle.""" if self._mode in [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_ECO]: return self._mode - elif self._mode == STATE_HEAT_COOL: + elif self._mode == NEST_MODE_HEAT_COOL: return STATE_AUTO return STATE_UNKNOWN @property def target_temperature(self): """Return the temperature we try to reach.""" - if self._mode != STATE_HEAT_COOL and not self.is_away_mode_on: + if self._mode != NEST_MODE_HEAT_COOL and not self.is_away_mode_on: return self._target_temperature return None @@ -125,7 +145,7 @@ def target_temperature_low(self): self._eco_temperature[0]: # eco_temperature is always a low, high tuple return self._eco_temperature[0] - if self._mode == STATE_HEAT_COOL: + if self._mode == NEST_MODE_HEAT_COOL: return self._target_temperature[0] return None @@ -136,7 +156,7 @@ def target_temperature_high(self): self._eco_temperature[1]: # eco_temperature is always a low, high tuple return self._eco_temperature[1] - if self._mode == STATE_HEAT_COOL: + if self._mode == NEST_MODE_HEAT_COOL: return self._target_temperature[1] return None @@ -147,22 +167,31 @@ def is_away_mode_on(self): def set_temperature(self, **kwargs): """Set new target temperature.""" + import nest target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) - if self._mode == STATE_HEAT_COOL: + 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) else: temp = kwargs.get(ATTR_TEMPERATURE) _LOGGER.debug("Nest set_temperature-output-value=%s", temp) - self.device.target = temp + try: + self.device.target = temp + except nest.nest.APIError: + _LOGGER.error("An error occurred while setting the temperature") def set_operation_mode(self, operation_mode): """Set operation mode.""" if operation_mode in [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_ECO]: device_mode = operation_mode elif operation_mode == STATE_AUTO: - device_mode = STATE_HEAT_COOL + 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 @@ -190,11 +219,14 @@ def current_fan_mode(self): @property def fan_list(self): """List of available fan modes.""" - return self._fan_list + if self._has_fan: + return self._fan_list + return None - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Turn fan on/off.""" - self.device.fan = fan.lower() + if self._has_fan: + self.device.fan = fan_mode.lower() @property def min_temp(self): @@ -210,7 +242,7 @@ def update(self): """Cache value from Python-nest.""" self._location = self.device.where self._name = self.device.name - self._humidity = self.device.humidity, + self._humidity = self.device.humidity self._temperature = self.device.temperature self._mode = self.device.mode self._target_temperature = self.device.target diff --git a/homeassistant/components/climate/netatmo.py b/homeassistant/components/climate/netatmo.py old mode 100755 new mode 100644 index 369b01e53de72..49452662fc43e --- a/homeassistant/components/climate/netatmo.py +++ b/homeassistant/components/climate/netatmo.py @@ -10,9 +10,9 @@ from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE from homeassistant.components.climate import ( - STATE_HEAT, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA) + 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'] @@ -23,7 +23,7 @@ CONF_THERMOSTAT = 'thermostat' DEFAULT_AWAY_TEMPERATURE = 14 -# # The default offeset is 2 hours (when you use the thermostat itself) +# # The default offset is 2 hours (when you use the thermostat itself) DEFAULT_TIME_OFFSET = 7200 # # Return cached results if last scan was less then this time ago # # NetAtmo Data is uploaded to server every hour @@ -35,10 +35,13 @@ vol.All(cv.ensure_list, [cv.string]), }) +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | + SUPPORT_AWAY_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 @@ -65,16 +68,16 @@ def __init__(self, data, module_name, away_temp=None): self._target_temperature = None self._away = None + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + @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._target_temperature - @property def temperature_unit(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py new file mode 100644 index 0000000000000..39c66ff94f204 --- /dev/null +++ b/homeassistant/components/climate/nuheat.py @@ -0,0 +1,227 @@ +""" +Support for NuHeat thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.nuheat/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.climate import ( + ClimateDevice, + DOMAIN, + SUPPORT_HOLD_MODE, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + STATE_AUTO, + STATE_HEAT, + STATE_IDLE) +from homeassistant.components.nuheat import DOMAIN as NUHEAT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +DEPENDENCIES = ["nuheat"] + +_LOGGER = logging.getLogger(__name__) + +ICON = "mdi:thermometer" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + +# Hold modes +MODE_AUTO = STATE_AUTO # Run device schedule +MODE_HOLD_TEMPERATURE = "temperature" +MODE_TEMPORARY_HOLD = "temporary_temperature" + +OPERATION_LIST = [STATE_HEAT, STATE_IDLE] + +SCHEDULE_HOLD = 3 +SCHEDULE_RUN = 1 +SCHEDULE_TEMPORARY_HOLD = 2 + +SERVICE_RESUME_PROGRAM = "nuheat_resume_program" + +RESUME_PROGRAM_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids +}) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_HOLD_MODE | + SUPPORT_OPERATION_MODE) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the NuHeat thermostat(s).""" + if discovery_info is None: + return + + temperature_unit = hass.config.units.temperature_unit + api, serial_numbers = hass.data[NUHEAT_DOMAIN] + thermostats = [ + NuHeatThermostat(api, serial_number, temperature_unit) + for serial_number in serial_numbers + ] + add_devices(thermostats, True) + + def resume_program_set_service(service): + """Resume the program on the target thermostats.""" + entity_id = service.data.get(ATTR_ENTITY_ID) + if entity_id: + target_thermostats = [device for device in thermostats + if device.entity_id in entity_id] + else: + target_thermostats = thermostats + + for thermostat in target_thermostats: + thermostat.resume_program() + + thermostat.schedule_update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_RESUME_PROGRAM, resume_program_set_service, + schema=RESUME_PROGRAM_SCHEMA) + + +class NuHeatThermostat(ClimateDevice): + """Representation of a NuHeat Thermostat.""" + + def __init__(self, api, serial_number, temperature_unit): + """Initialize the thermostat.""" + self._thermostat = api.get_thermostat(serial_number) + self._temperature_unit = temperature_unit + self._force_update = False + + @property + def name(self): + """Return the name of the thermostat.""" + return self._thermostat.room + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + if self._temperature_unit == "C": + return TEMP_CELSIUS + + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._temperature_unit == "C": + return self._thermostat.celsius + + return self._thermostat.fahrenheit + + @property + def current_operation(self): + """Return current operation. ie. heat, idle.""" + if self._thermostat.heating: + return STATE_HEAT + + return STATE_IDLE + + @property + def min_temp(self): + """Return the minimum supported temperature for the thermostat.""" + if self._temperature_unit == "C": + return self._thermostat.min_celsius + + return self._thermostat.min_fahrenheit + + @property + def max_temp(self): + """Return the maximum supported temperature for the thermostat.""" + if self._temperature_unit == "C": + return self._thermostat.max_celsius + + return self._thermostat.max_fahrenheit + + @property + def target_temperature(self): + """Return the currently programmed temperature.""" + if self._temperature_unit == "C": + return self._thermostat.target_celsius + + return self._thermostat.target_fahrenheit + + @property + def current_hold_mode(self): + """Return current hold mode.""" + schedule_mode = self._thermostat.schedule_mode + if schedule_mode == SCHEDULE_RUN: + return MODE_AUTO + + if schedule_mode == SCHEDULE_HOLD: + return MODE_HOLD_TEMPERATURE + + if schedule_mode == SCHEDULE_TEMPORARY_HOLD: + return MODE_TEMPORARY_HOLD + + return MODE_AUTO + + @property + def operation_list(self): + """Return list of possible operation modes.""" + return OPERATION_LIST + + def resume_program(self): + """Resume the thermostat's programmed schedule.""" + self._thermostat.resume_schedule() + self._force_update = True + + def set_hold_mode(self, hold_mode): + """Update the hold mode of the thermostat.""" + if hold_mode == MODE_AUTO: + schedule_mode = SCHEDULE_RUN + + if hold_mode == MODE_HOLD_TEMPERATURE: + schedule_mode = SCHEDULE_HOLD + + if hold_mode == MODE_TEMPORARY_HOLD: + schedule_mode = SCHEDULE_TEMPORARY_HOLD + + self._thermostat.schedule_mode = schedule_mode + self._force_update = True + + def set_temperature(self, **kwargs): + """Set a new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if self._temperature_unit == "C": + self._thermostat.target_celsius = temperature + else: + self._thermostat.target_fahrenheit = temperature + + _LOGGER.debug( + "Setting NuHeat thermostat temperature to %s %s", + temperature, self.temperature_unit) + + self._force_update = True + + def update(self): + """Get the latest state from the thermostat.""" + if self._force_update: + self._throttled_update(no_throttle=True) + self._force_update = False + else: + self._throttled_update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _throttled_update(self, **kwargs): + """Get the latest state from the thermostat with a throttle.""" + self._thermostat.get_data() diff --git a/homeassistant/components/climate/oem.py b/homeassistant/components/climate/oem.py index 5909f26eb4f94..59f8db033187c 100644 --- a/homeassistant/components/climate/oem.py +++ b/homeassistant/components/climate/oem.py @@ -14,7 +14,8 @@ # Import the device class from the component that you want to support from homeassistant.components.climate import ( - ClimateDevice, PLATFORM_SCHEMA, STATE_HEAT, STATE_IDLE, ATTR_TEMPERATURE) + ClimateDevice, PLATFORM_SCHEMA, STATE_HEAT, STATE_IDLE, ATTR_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_AWAY_MODE) from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, TEMP_CELSIUS, CONF_NAME) import homeassistant.helpers.config_validation as cv @@ -34,6 +35,8 @@ vol.Optional(CONF_AWAY_TEMP, default=14): vol.Coerce(float) }) +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the oemthermostat platform.""" @@ -56,7 +59,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class ThermostatDevice(ClimateDevice): - """Interface class for the oemthermostat modul.""" + """Interface class for the oemthermostat module.""" def __init__(self, hass, thermostat, name, away_temp): """Initialize the device.""" @@ -77,6 +80,11 @@ def __init__(self, hass, thermostat, name, away_temp): self._temperature = None self._setpoint = None + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + @property def name(self): """Return the name of this Thermostat.""" diff --git a/homeassistant/components/climate/proliphix.py b/homeassistant/components/climate/proliphix.py index f168df04158c5..34fcfd667b6a7 100644 --- a/homeassistant/components/climate/proliphix.py +++ b/homeassistant/components/climate/proliphix.py @@ -8,7 +8,7 @@ from homeassistant.components.climate import ( PRECISION_TENTHS, STATE_COOL, STATE_HEAT, STATE_IDLE, - ClimateDevice, PLATFORM_SCHEMA) + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) import homeassistant.helpers.config_validation as cv @@ -46,6 +46,11 @@ def __init__(self, pdp): self._pdp.update() self._name = self._pdp.name + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + @property def should_poll(self): """Set up polling needed for thermostat.""" diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py index 6daeebf9f5512..032d85637ef50 100644 --- a/homeassistant/components/climate/radiotherm.py +++ b/homeassistant/components/climate/radiotherm.py @@ -4,15 +4,18 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.radiotherm/ """ +import asyncio import datetime import logging import voluptuous as vol from homeassistant.components.climate import ( - STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_IDLE, STATE_OFF, - ClimateDevice, PLATFORM_SCHEMA) -from homeassistant.const import CONF_HOST, TEMP_FAHRENHEIT, ATTR_TEMPERATURE + STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_IDLE, STATE_ON, STATE_OFF, + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_OPERATION_MODE, SUPPORT_FAN_MODE, SUPPORT_AWAY_MODE) +from homeassistant.const import ( + CONF_HOST, TEMP_FAHRENHEIT, ATTR_TEMPERATURE, PRECISION_HALVES) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['radiotherm==1.3'] @@ -29,15 +32,56 @@ DEFAULT_AWAY_TEMPERATURE_HEAT = 60 DEFAULT_AWAY_TEMPERATURE_COOL = 85 +STATE_CIRCULATE = "circulate" + +OPERATION_LIST = [STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_OFF] +CT30_FAN_OPERATION_LIST = [STATE_ON, STATE_AUTO] +CT80_FAN_OPERATION_LIST = [STATE_ON, STATE_CIRCULATE, STATE_AUTO] + +# Mappings from radiotherm json data codes to and from HASS state +# flags. CODE is the thermostat integer code and these map to and +# from HASS state flags. + +# Programmed temperature mode of the thermostat. +CODE_TO_TEMP_MODE = {0: STATE_OFF, 1: STATE_HEAT, 2: STATE_COOL, 3: STATE_AUTO} +TEMP_MODE_TO_CODE = {v: k for k, v in CODE_TO_TEMP_MODE.items()} + +# Programmed fan mode (circulate is supported by CT80 models) +CODE_TO_FAN_MODE = {0: STATE_AUTO, 1: STATE_CIRCULATE, 2: STATE_ON} +FAN_MODE_TO_CODE = {v: k for k, v in CODE_TO_FAN_MODE.items()} + +# Active thermostat state (is it heating or cooling?). In the future +# this should probably made into heat and cool binary sensors. +CODE_TO_TEMP_STATE = {0: STATE_IDLE, 1: STATE_HEAT, 2: STATE_COOL} + +# Active fan state. This is if the fan is actually on or not. In the +# future this should probably made into a binary sensor for the fan. +CODE_TO_FAN_STATE = {0: STATE_OFF, 1: STATE_ON} + + +def round_temp(temperature): + """Round a temperature to the resolution of the thermostat. + + RadioThermostats can handle 0.5 degree temps so the input + temperature is rounded to that value and returned. + """ + return round(temperature * 2.0) / 2.0 + + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean, vol.Optional(CONF_AWAY_TEMPERATURE_HEAT, - default=DEFAULT_AWAY_TEMPERATURE_HEAT): vol.Coerce(float), + default=DEFAULT_AWAY_TEMPERATURE_HEAT): + vol.All(vol.Coerce(float), round_temp), vol.Optional(CONF_AWAY_TEMPERATURE_COOL, - default=DEFAULT_AWAY_TEMPERATURE_COOL): vol.Coerce(float), + default=DEFAULT_AWAY_TEMPERATURE_COOL): + vol.All(vol.Coerce(float), round_temp), }) +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | + SUPPORT_FAN_MODE | SUPPORT_AWAY_MODE) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Radio Thermostat.""" @@ -77,19 +121,39 @@ class RadioThermostat(ClimateDevice): def __init__(self, device, hold_temp, away_temps): """Initialize the thermostat.""" self.device = device - self.set_time() self._target_temperature = None self._current_temperature = None self._current_operation = STATE_IDLE self._name = None self._fmode = None + self._fstate = None self._tmode = None self._tstate = None self._hold_temp = hold_temp + self._hold_set = False self._away = False self._away_temps = away_temps self._prev_temp = None - self._operation_list = [STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_OFF] + + # Fan circulate mode is only supported by the CT80 models. + import radiotherm + self._is_model_ct80 = isinstance(self.device, + radiotherm.thermostat.CT80) + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + # Set the time on the device. This shouldn't be in the + # constructor because it's a network call. We can't put it in + # update() because calling it will clear any temporary mode or + # temperature in the thermostat. So add it as a future job + # for the event loop to run. + self.hass.async_add_job(self.set_time) @property def name(self): @@ -101,6 +165,11 @@ def temperature_unit(self): """Return the unit of measurement.""" return TEMP_FAHRENHEIT + @property + def precision(self): + """Return the precision of the system.""" + return PRECISION_HALVES + @property def device_state_attributes(self): """Return the device specific state attributes.""" @@ -109,6 +178,24 @@ def device_state_attributes(self): ATTR_MODE: self._tmode, } + @property + def fan_list(self): + """List of available fan modes.""" + if self._is_model_ct80: + return CT80_FAN_OPERATION_LIST + return CT30_FAN_OPERATION_LIST + + @property + def current_fan_mode(self): + """Return whether the fan is on.""" + return self._fmode + + def set_fan_mode(self, fan_mode): + """Turn fan on/off.""" + code = FAN_MODE_TO_CODE.get(fan_mode, None) + if code is not None: + self.device.fmode = code + @property def current_temperature(self): """Return the current temperature.""" @@ -122,7 +209,7 @@ def current_operation(self): @property def operation_list(self): """Return the operation modes list.""" - return self._operation_list + return OPERATION_LIST @property def target_temperature(self): @@ -136,53 +223,48 @@ def is_away_mode_on(self): def update(self): """Update and validate the data from the thermostat.""" - current_temp = self.device.temp['raw'] + # Radio thermostats are very slow, and sometimes don't respond + # very quickly. So we need to keep the number of calls to them + # to a bare minimum or we'll hit the HASS 10 sec warning. We + # have to make one call to /tstat to get temps but we'll try and + # keep the other calls to a minimum. Even with this, these + # thermostats tend to time out sometimes when they're actively + # heating or cooling. + + # First time - get the name from the thermostat. This is + # normally set in the radio thermostat web app. + if self._name is None: + self._name = self.device.name['raw'] + + # Request the current state from the thermostat. + data = self.device.tstat['raw'] + + current_temp = data['temp'] if current_temp == -1: - _LOGGER.error("Couldn't get valid temperature reading") + _LOGGER.error('%s (%s) was busy (temp == -1)', self._name, + self.device.host) return + + # Map thermostat values into various STATE_ flags. self._current_temperature = current_temp - self._name = self.device.name['raw'] - try: - self._fmode = self.device.fmode['human'] - except AttributeError: - _LOGGER.error("Couldn't get valid fan mode reading") - try: - self._tmode = self.device.tmode['human'] - except AttributeError: - _LOGGER.error("Couldn't get valid thermostat mode reading") - try: - self._tstate = self.device.tstate['human'] - except AttributeError: - _LOGGER.error("Couldn't get valid thermostat state reading") - - if self._tmode == 'Cool': - target_temp = self.device.t_cool['raw'] - if target_temp == -1: - _LOGGER.error("Couldn't get target reading") - return - self._target_temperature = target_temp - self._current_operation = STATE_COOL - elif self._tmode == 'Heat': - target_temp = self.device.t_heat['raw'] - if target_temp == -1: - _LOGGER.error("Couldn't get valid target reading") - return - self._target_temperature = target_temp - self._current_operation = STATE_HEAT - elif self._tmode == 'Auto': - if self._tstate == 'Cool': - target_temp = self.device.t_cool['raw'] - if target_temp == -1: - _LOGGER.error("Couldn't get valid target reading") - return - self._target_temperature = target_temp - elif self._tstate == 'Heat': - target_temp = self.device.t_heat['raw'] - if target_temp == -1: - _LOGGER.error("Couldn't get valid target reading") - return - self._target_temperature = target_temp - self._current_operation = STATE_AUTO + self._fmode = CODE_TO_FAN_MODE[data['fmode']] + self._fstate = CODE_TO_FAN_STATE[data['fstate']] + self._tmode = CODE_TO_TEMP_MODE[data['tmode']] + self._tstate = CODE_TO_TEMP_STATE[data['tstate']] + + self._current_operation = self._tmode + if self._tmode == STATE_COOL: + self._target_temperature = data['t_cool'] + elif self._tmode == STATE_HEAT: + self._target_temperature = data['t_heat'] + elif self._tmode == STATE_AUTO: + # This doesn't really work - tstate is only set if the HVAC is + # active. If it's idle, we don't know what to do with the target + # temperature. + if self._tstate == STATE_COOL: + self._target_temperature = data['t_cool'] + elif self._tstate == STATE_HEAT: + self._target_temperature = data['t_heat'] else: self._current_operation = STATE_IDLE @@ -191,23 +273,32 @@ def set_temperature(self, **kwargs): temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return + + temperature = round_temp(temperature) + if self._current_operation == STATE_COOL: - self.device.t_cool = round(temperature * 2.0) / 2.0 + self.device.t_cool = temperature elif self._current_operation == STATE_HEAT: - self.device.t_heat = round(temperature * 2.0) / 2.0 + self.device.t_heat = temperature elif self._current_operation == STATE_AUTO: - if self._tstate == 'Cool': - self.device.t_cool = round(temperature * 2.0) / 2.0 - elif self._tstate == 'Heat': - self.device.t_heat = round(temperature * 2.0) / 2.0 - - if self._hold_temp or self._away: - self.device.hold = 1 - else: - self.device.hold = 0 + if self._tstate == STATE_COOL: + self.device.t_cool = temperature + elif self._tstate == STATE_HEAT: + self.device.t_heat = temperature + + # Only change the hold if requested or if hold mode was turned + # on and we haven't set it yet. + if kwargs.get('hold_changed', False) or not self._hold_set: + if self._hold_temp or self._away: + self.device.hold = 1 + self._hold_set = True + else: + self.device.hold = 0 def set_time(self): """Set device time.""" + # Calling this clears any local temperature override and + # reverts to the scheduled temperature. now = datetime.datetime.now() self.device.time = { 'day': now.weekday(), @@ -217,14 +308,14 @@ def set_time(self): def set_operation_mode(self, operation_mode): """Set operation mode (auto, cool, heat, off).""" - if operation_mode == STATE_OFF: - self.device.tmode = 0 - elif operation_mode == STATE_AUTO: - self.device.tmode = 3 + if operation_mode == STATE_OFF or operation_mode == STATE_AUTO: + self.device.tmode = TEMP_MODE_TO_CODE[operation_mode] + + # Setting t_cool or t_heat automatically changes tmode. elif operation_mode == STATE_COOL: - self.device.t_cool = round(self._target_temperature * 2.0) / 2.0 + self.device.t_cool = self._target_temperature elif operation_mode == STATE_HEAT: - self.device.t_heat = round(self._target_temperature * 2.0) / 2.0 + self.device.t_heat = self._target_temperature def turn_away_mode_on(self): """Turn away on. @@ -238,10 +329,11 @@ def turn_away_mode_on(self): away_temp = self._away_temps[0] elif self._current_operation == STATE_COOL: away_temp = self._away_temps[1] + self._away = True - self.set_temperature(temperature=away_temp) + self.set_temperature(temperature=away_temp, hold_changed=True) def turn_away_mode_off(self): """Turn away off.""" self._away = False - self.set_temperature(temperature=self._prev_temp) + self.set_temperature(temperature=self._prev_temp, hold_changed=True) diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index c55b4c9ce0d90..2b92d050d3b0f 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -13,31 +13,50 @@ import voluptuous as vol from homeassistant.const import ( - ATTR_TEMPERATURE, CONF_API_KEY, CONF_ID, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_ENTITY_ID, ATTR_STATE, ATTR_TEMPERATURE, CONF_API_KEY, CONF_ID, + STATE_ON, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) from homeassistant.components.climate import ( - ATTR_CURRENT_HUMIDITY, ClimateDevice, PLATFORM_SCHEMA) + ATTR_CURRENT_HUMIDITY, ClimateDevice, DOMAIN, PLATFORM_SCHEMA, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, + SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, + SUPPORT_ON_OFF) 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.1'] +REQUIREMENTS = ['pysensibo==1.0.3'] _LOGGER = logging.getLogger(__name__) -ALL = 'all' +ALL = ['all'] TIMEOUT = 10 +SERVICE_ASSUME_STATE = 'sensibo_assume_state' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_ID, default=ALL): vol.All(cv.ensure_list, [cv.string]), }) +ASSUME_STATE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_STATE): cv.string, +}) + _FETCH_FIELDS = ','.join([ 'room{name}', 'measurements', 'remoteCapabilities', - 'acState', 'connectionStatus{isAlive}']) + 'acState', 'connectionStatus{isAlive}', 'temperatureUnit']) _INITIAL_FETCH_FIELDS = 'id,' + _FETCH_FIELDS +FIELD_TO_FLAG = { + 'fanLevel': SUPPORT_FAN_MODE, + 'mode': SUPPORT_OPERATION_MODE, + 'swing': SUPPORT_SWING_MODE, + 'targetTemperature': SUPPORT_TARGET_TEMPERATURE, + 'on': SUPPORT_ON_OFF, +} + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -55,15 +74,37 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): devices.append(SensiboClimate(client, dev)) except (aiohttp.client_exceptions.ClientConnectorError, asyncio.TimeoutError): - _LOGGER.exception('Failed to connct to Sensibo servers.') + _LOGGER.exception('Failed to connect to Sensibo servers.') raise PlatformNotReady if devices: async_add_devices(devices) + @asyncio.coroutine + def async_assume_state(service): + """Set state according to external service call..""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + target_climate = [device for device in devices + if device.entity_id in entity_ids] + else: + target_climate = devices + + update_tasks = [] + for climate in target_climate: + yield from climate.async_assume_state( + service.data.get(ATTR_STATE)) + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) + hass.services.async_register( + DOMAIN, SERVICE_ASSUME_STATE, async_assume_state, + schema=ASSUME_STATE_SCHEMA) + class SensiboClimate(ClimateDevice): - """Representation os a Sensibo device.""" + """Representation of a Sensibo device.""" def __init__(self, client, data): """Build SensiboClimate. @@ -73,8 +114,14 @@ def __init__(self, client, data): """ self._client = client self._id = data['id'] + self._external_state = None self._do_update(data) + @property + def supported_features(self): + """Return the list of supported features.""" + return self._supported_features + def _do_update(self, data): self._name = data['room']['name'] self._measurements = data['measurements'] @@ -84,11 +131,25 @@ def _do_update(self, data): self._operations = sorted(capabilities['modes'].keys()) self._current_capabilities = capabilities[ 'modes'][self.current_operation] - temperature_unit_key = self._ac_states['temperatureUnit'] - self._temperature_unit = \ - TEMP_CELSIUS if temperature_unit_key == 'C' else TEMP_FAHRENHEIT - self._temperatures_list = self._current_capabilities[ - 'temperatures'][temperature_unit_key]['values'] + temperature_unit_key = data.get('temperatureUnit') or \ + self._ac_states.get('temperatureUnit') + if temperature_unit_key: + self._temperature_unit = TEMP_CELSIUS if \ + temperature_unit_key == 'C' else TEMP_FAHRENHEIT + self._temperatures_list = self._current_capabilities[ + 'temperatures'].get(temperature_unit_key, {}).get('values', []) + else: + self._temperature_unit = self.unit_of_measurement + self._temperatures_list = [] + self._supported_features = 0 + for key in self._ac_states: + if key in FIELD_TO_FLAG: + self._supported_features |= FIELD_TO_FLAG[key] + + @property + def state(self): + """Return the current state.""" + return self._external_state or super().state @property def device_state_attributes(self): @@ -108,7 +169,7 @@ def available(self): @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._ac_states['targetTemperature'] + return self._ac_states.get('targetTemperature') @property def target_temperature_step(self): @@ -133,10 +194,8 @@ def current_humidity(self): @property def current_temperature(self): """Return the current temperature.""" - # This field is not affected by temperature_unit. - # It is always in C / nativeTemperatureUnit - if 'nativeTemperatureUnit' not in self._ac_states: - return self._measurements['temperature'] + # This field is not affected by temperatureUnit. + # It is always in C return convert_temperature( self._measurements['temperature'], TEMP_CELSIUS, @@ -173,19 +232,26 @@ def name(self): return self._name @property - def is_aux_heat_on(self): + def is_on(self): """Return true if AC is on.""" return self._ac_states['on'] @property def min_temp(self): """Return the minimum temperature.""" - return self._temperatures_list[0] + return self._temperatures_list[0] \ + if self._temperatures_list else super().min_temp @property def max_temp(self): """Return the maximum temperature.""" - return self._temperatures_list[-1] + return self._temperatures_list[-1] \ + if self._temperatures_list else super().max_temp + + @property + def unique_id(self): + """Return unique ID based on Sensibo ID.""" + return self._id @asyncio.coroutine def async_set_temperature(self, **kwargs): @@ -209,42 +275,62 @@ def async_set_temperature(self, **kwargs): with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'targetTemperature', temperature) + self._id, 'targetTemperature', temperature, self._ac_states) @asyncio.coroutine - def async_set_fan_mode(self, fan): + def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'fanLevel', fan) + self._id, 'fanLevel', fan_mode, self._ac_states) @asyncio.coroutine def async_set_operation_mode(self, operation_mode): """Set new target operation mode.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'mode', operation_mode) + self._id, 'mode', operation_mode, self._ac_states) @asyncio.coroutine def async_set_swing_mode(self, swing_mode): """Set new target swing operation.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'swing', swing_mode) + self._id, 'swing', swing_mode, self._ac_states) @asyncio.coroutine - def async_turn_aux_heat_on(self): + def async_turn_on(self): """Turn Sensibo unit on.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'on', True) + self._id, 'on', True, self._ac_states) @asyncio.coroutine - def async_turn_aux_heat_off(self): + def async_turn_off(self): """Turn Sensibo unit on.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'on', False) + self._id, 'on', False, self._ac_states) + + @asyncio.coroutine + def async_assume_state(self, state): + """Set external state.""" + change_needed = (state != STATE_OFF and not self.is_on) \ + or (state == STATE_OFF and self.is_on) + if change_needed: + with async_timeout.timeout(TIMEOUT): + yield from self._client.async_set_ac_state_property( + self._id, + 'on', + state != STATE_OFF, # value + self._ac_states, + True # assumed_state + ) + + if state in [STATE_ON, STATE_OFF]: + self._external_state = None + else: + self._external_state = state @asyncio.coroutine def async_update(self): diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 4aebb1c85c998..fbb21962c6ee3 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -1,132 +1,154 @@ -set_aux_heat: - description: Turn auxillary heater on/off for climate device +# Describes the format for available climate services +set_aux_heat: + description: Turn auxiliary heater on/off for climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - aux_heat: - description: New value of axillary heater + description: New value of axillary heater. example: true - set_away_mode: - description: Turn away mode on/off for climate device - + description: Turn away mode on/off for climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - away_mode: - description: New value of away mode + description: New value of away mode. example: true - set_hold_mode: - description: Turn hold mode for climate device - + description: Turn hold mode for climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - hold_mode: description: New value of hold mode example: 'away' - set_temperature: - description: Set target temperature of climate device - + description: Set target temperature of climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - temperature: - description: New target temperature for hvac + description: New target temperature for HVAC. example: 25 - target_temp_high: - description: New target high tempereature for hvac + description: New target high tempereature for HVAC. example: 26 - target_temp_low: - description: New target low temperature for hvac + description: New target low temperature for HVAC. example: 20 - operation_mode: description: Operation mode to set temperature to. This defaults to current_operation mode if not set, or set incorrectly. example: 'Heat' - set_humidity: - description: Set target humidity of climate device - + description: Set target humidity of climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - humidity: - description: New target humidity for climate device + description: New target humidity for climate device. example: 60 - set_fan_mode: - description: Set fan operation for climate device - + description: Set fan operation for climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.nest' - fan_mode: - description: New value of fan mode + description: New value of fan mode. example: On Low - set_operation_mode: - description: Set operation mode for climate device - + description: Set operation mode for climate device. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.nest' - operation_mode: - description: New value of operation mode + description: New value of operation mode. example: Heat - - set_swing_mode: - description: Set swing operation for climate device - + description: Set swing operation for climate device. fields: entity_id: - description: Name(s) of entities to change - example: '.nest' - + description: Name(s) of entities to change. + example: 'climate.nest' swing_mode: - description: New value of swing mode - example: 1 + description: New value of swing mode. + example: -ecobee_set_fan_min_on_time: - description: Set the minimum fan on time +turn_on: + description: Turn climate device on. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' +turn_off: + description: Turn climate device off. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' +ecobee_set_fan_min_on_time: + description: Set the minimum fan on time. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' fan_min_on_time: - description: New value of fan min on time + description: New value of fan min on time. example: 5 ecobee_resume_program: - description: Resume the programmed schedule - + description: Resume the programmed schedule. fields: entity_id: - description: Name(s) of entities to change + description: Name(s) of entities to change. example: 'climate.kitchen' - resume_all: description: Resume all events and return to the scheduled program. This default to false which removes only the top event. example: true + +nuheat_resume_program: + description: Resume the programmed schedule. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + +econet_add_vacation: + description: Add a vacation to your water heater. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.water_heater' + start_date: + description: The timestamp of when the vacation should start. (Optional, defaults to now) + example: 1513186320 + end_date: + description: The timestamp of when the vacation should end. + example: 1513445520 + +econet_delete_vacation: + description: Delete your existing vacation from your water heater. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.water_heater' + +sensibo_assume_state: + description: Set Sensibo device to external state. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + state: + description: State to set. + example: 'idle' diff --git a/homeassistant/components/climate/tado.py b/homeassistant/components/climate/tado.py index 00bed936bd799..437c8ec3371bf 100644 --- a/homeassistant/components/climate/tado.py +++ b/homeassistant/components/climate/tado.py @@ -6,8 +6,9 @@ """ import logging -from homeassistant.const import TEMP_CELSIUS -from homeassistant.components.climate import ClimateDevice +from homeassistant.const import (PRECISION_TENTHS, TEMP_CELSIUS) +from homeassistant.components.climate import ( + ClimateDevice, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) from homeassistant.const import ATTR_TEMPERATURE from homeassistant.components.tado import DATA_TADO @@ -43,6 +44,8 @@ CONST_MODE_OFF: 'Off', } +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Tado climate platform.""" @@ -56,8 +59,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): climate_devices = [] for zone in zones: - climate_devices.append(create_climate_device( - tado, hass, zone, zone['name'], zone['id'])) + device = create_climate_device( + tado, hass, zone, zone['name'], zone['id']) + if not device: + continue + climate_devices.append(device) if climate_devices: add_devices(climate_devices, True) @@ -72,8 +78,11 @@ def create_climate_device(tado, hass, zone, name, zone_id): if ac_mode: temperatures = capabilities['HEAT']['temperatures'] - else: + elif 'temperatures' in capabilities: temperatures = capabilities['temperatures'] + else: + _LOGGER.debug("Received zone %s has no temperature; not adding", name) + return min_temp = float(temperatures['celsius']['min']) max_temp = float(temperatures['celsius']['max']) @@ -127,6 +136,11 @@ def __init__(self, store, zone_name, zone_id, data_id, self._current_operation = CONST_MODE_SMART_SCHEDULE self._overlay_mode = CONST_MODE_SMART_SCHEDULE + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + @property def name(self): """Return the name of the device.""" @@ -178,6 +192,11 @@ def is_away_mode_on(self): """Return true if away mode is on.""" return self._is_away + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return PRECISION_TENTHS + @property def target_temperature(self): """Return the temperature we try to reach.""" @@ -194,6 +213,7 @@ def set_temperature(self, **kwargs): self._target_temp = temperature self._control_heating() + # pylint: disable=arguments-differ def set_operation_mode(self, readable_operation_mode): """Set new operation mode.""" operation_mode = CONST_MODE_SMART_SCHEDULE @@ -230,7 +250,7 @@ def update(self): data = self._store.get_data(self._data_id) if data is None: - _LOGGER.debug("Recieved no data for zone %s", self.zone_name) + _LOGGER.debug("Received no data for zone %s", self.zone_name) return if 'sensorDataPoints' in data: @@ -275,7 +295,7 @@ def update(self): overlay = False overlay_data = None - termination = self._current_operation + termination = CONST_MODE_SMART_SCHEDULE cooling = False fan_speed = CONST_MODE_OFF @@ -298,7 +318,7 @@ def update(self): fan_speed = setting_data['fanSpeed'] if self._device_is_active: - # If you set mode manualy to off, there will be an overlay + # If you set mode manually to off, there will be an overlay # and a termination, but we want to see the mode "OFF" self._overlay_mode = termination self._current_operation = termination diff --git a/homeassistant/components/climate/tesla.py b/homeassistant/components/climate/tesla.py index 39d002e72d9b1..225c13d975dc3 100644 --- a/homeassistant/components/climate/tesla.py +++ b/homeassistant/components/climate/tesla.py @@ -6,11 +6,13 @@ """ import logging -from homeassistant.const import STATE_ON, STATE_OFF -from homeassistant.components.climate import ClimateDevice, ENTITY_ID_FORMAT -from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.components.climate import ( + ENTITY_ID_FORMAT, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, + ClimateDevice) +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN +from homeassistant.components.tesla import TeslaDevice from homeassistant.const import ( - TEMP_FAHRENHEIT, TEMP_CELSIUS, ATTR_TEMPERATURE) + ATTR_TEMPERATURE, STATE_OFF, STATE_ON, TEMP_CELSIUS, TEMP_FAHRENHEIT) _LOGGER = logging.getLogger(__name__) @@ -18,6 +20,8 @@ OPERATION_LIST = [STATE_ON, STATE_OFF] +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Tesla climate platform.""" @@ -35,7 +39,11 @@ def __init__(self, tesla_device, controller): self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) self._target_temperature = None self._temperature = None - self._name = self.tesla_device.name + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS @property def current_operation(self): @@ -43,8 +51,7 @@ def current_operation(self): mode = self.tesla_device.is_hvac_enabled() if mode: return OPERATION_LIST[0] # On - else: - return OPERATION_LIST[1] # Off + return OPERATION_LIST[1] # Off @property def operation_list(self): @@ -52,7 +59,7 @@ def operation_list(self): return OPERATION_LIST def update(self): - """Called by the Tesla device callback to update state.""" + """Call by the Tesla device callback to update state.""" _LOGGER.debug("Updating: %s", self._name) self.tesla_device.update() self._target_temperature = self.tesla_device.get_goal_temp() diff --git a/homeassistant/components/climate/toon.py b/homeassistant/components/climate/toon.py new file mode 100644 index 0000000000000..330801fc23190 --- /dev/null +++ b/homeassistant/components/climate/toon.py @@ -0,0 +1,97 @@ +""" +Toon van Eneco Thermostat Support. + +This provides a component for the rebranded Quby thermostat as provided by +Eneco. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.toon/ +""" +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, STATE_COOL, STATE_ECO, STATE_HEAT, STATE_PERFORMANCE, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) +import homeassistant.components.toon as toon_main +from homeassistant.const import TEMP_CELSIUS + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Toon climate device.""" + add_devices([ThermostatDevice(hass)], True) + + +class ThermostatDevice(ClimateDevice): + """Representation of a Toon climate device.""" + + def __init__(self, hass): + """Initialize the Toon climate device.""" + self._name = 'Toon van Eneco' + self.hass = hass + self.thermos = hass.data[toon_main.TOON_HANDLE] + + self._state = None + self._temperature = None + self._setpoint = None + self._operation_list = [ + STATE_PERFORMANCE, + STATE_HEAT, + STATE_ECO, + STATE_COOL, + ] + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the name of this thermostat.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def current_operation(self): + """Return current operation i.e. comfort, home, away.""" + state = self.thermos.get_data('state') + return state + + @property + def operation_list(self): + """Return a list of available operation modes.""" + return self._operation_list + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.thermos.get_data('temp') + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.thermos.get_data('setpoint') + + def set_temperature(self, **kwargs): + """Change the setpoint of the thermostat.""" + temp = kwargs.get(ATTR_TEMPERATURE) + self.thermos.set_temp(temp) + + def set_operation_mode(self, operation_mode): + """Set new operation mode.""" + toonlib_values = { + STATE_PERFORMANCE: 'Comfort', + STATE_HEAT: 'Home', + STATE_ECO: 'Away', + STATE_COOL: 'Sleep', + } + + self.thermos.set_state(toonlib_values[operation_mode]) + + def update(self): + """Update local state.""" + self.thermos.update() diff --git a/homeassistant/components/climate/touchline.py b/homeassistant/components/climate/touchline.py new file mode 100644 index 0000000000000..f9c5676629bc8 --- /dev/null +++ b/homeassistant/components/climate/touchline.py @@ -0,0 +1,90 @@ +""" +Platform for Roth Touchline heat pump controller. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.touchline/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE) +from homeassistant.const import CONF_HOST, TEMP_CELSIUS, ATTR_TEMPERATURE +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pytouchline==0.7'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Touchline devices.""" + from pytouchline import PyTouchline + host = config[CONF_HOST] + py_touchline = PyTouchline() + number_of_devices = int(py_touchline.get_number_of_devices(host)) + devices = [] + for device_id in range(0, number_of_devices): + devices.append(Touchline(PyTouchline(device_id))) + add_devices(devices, True) + + +class Touchline(ClimateDevice): + """Representation of a Touchline device.""" + + def __init__(self, touchline_thermostat): + """Initialize the climate device.""" + self.unit = touchline_thermostat + self._name = None + self._current_temperature = None + self._target_temperature = None + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + def update(self): + """Update unit attributes.""" + self.unit.update() + self._name = self.unit.get_name() + self._current_temperature = self.unit.get_current_temperature() + self._target_temperature = self.unit.get_target_temperature() + + @property + def should_poll(self): + """Return the polling state.""" + return True + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @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 kwargs.get(ATTR_TEMPERATURE) is not None: + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + self.unit.set_target_temperature(self._target_temperature) diff --git a/homeassistant/components/climate/venstar.py b/homeassistant/components/climate/venstar.py new file mode 100644 index 0000000000000..c2b82e1cc8449 --- /dev/null +++ b/homeassistant/components/climate/venstar.py @@ -0,0 +1,314 @@ +""" +Support for Venstar WiFi Thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.venstar/ +""" +import logging + +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_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, + TEMP_FAHRENHEIT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['venstarcolortouch==0.6'] + +_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)), + vol.Optional(CONF_USERNAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Venstar thermostat.""" + import venstarcolortouch + + username = config.get(CONF_USERNAME) + 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' + else: + proto = 'http' + + client = venstarcolortouch.VenstarColorTouch( + addr=host, timeout=timeout, user=username, password=password, + proto=proto) + + add_devices([VenstarThermostat(client, humidifier)], True) + + +class VenstarThermostat(ClimateDevice): + """Representation of a Venstar thermostat.""" + + def __init__(self, client, humidifier): + """Initialize the thermostat.""" + self._client = client + self._humidifier = humidifier + + def update(self): + """Update the data from the thermostat.""" + info_success = self._client.update_info() + sensor_success = self._client.update_sensors() + if not info_success or not sensor_success: + _LOGGER.error("Failed to update data") + + @property + def supported_features(self): + """Return the list of supported features.""" + features = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_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._humidifier and + hasattr(self._client, 'hum_active')): + features |= (SUPPORT_TARGET_HUMIDITY | + SUPPORT_TARGET_HUMIDITY_HIGH | + SUPPORT_TARGET_HUMIDITY_LOW) + + return features + + @property + def name(self): + """Return the name of the thermostat.""" + return self._client.name + + @property + def precision(self): + """Return the precision of the system. + + Venstar temperature values are passed back and forth in the + API as whole degrees C or F. + """ + return PRECISION_WHOLE + + @property + def temperature_unit(self): + """Return the unit of measurement, as defined by the API.""" + if self._client.tempunits == self._client.TEMPUNITS_F: + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return VALID_FAN_STATES + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return VALID_THERMOSTAT_MODES + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._client.get_indoor_temp() + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._client.get_indoor_humidity() + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + if self._client.mode == self._client.MODE_HEAT: + return STATE_HEAT + elif self._client.mode == self._client.MODE_COOL: + return STATE_COOL + elif self._client.mode == self._client.MODE_AUTO: + return STATE_AUTO + return STATE_OFF + + @property + def current_fan_mode(self): + """Return the fan setting.""" + if self._client.fan == self._client.FAN_AUTO: + return STATE_AUTO + return STATE_ON + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + return { + ATTR_FAN_STATE: self._client.fanstate, + ATTR_HVAC_STATE: self._client.state, + } + + @property + def target_temperature(self): + """Return the target temperature we try to reach.""" + if self._client.mode == self._client.MODE_HEAT: + return self._client.heattemp + elif self._client.mode == self._client.MODE_COOL: + return self._client.cooltemp + return None + + @property + def target_temperature_low(self): + """Return the lower bound temp if auto mode is on.""" + if self._client.mode == self._client.MODE_AUTO: + return self._client.heattemp + return None + + @property + def target_temperature_high(self): + """Return the upper bound temp if auto mode is on.""" + if self._client.mode == self._client.MODE_AUTO: + return self._client.cooltemp + return None + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._client.hum_setpoint + + @property + def min_humidity(self): + """Return the minimum humidity. Hardcoded to 0 in API.""" + return 0 + + @property + def max_humidity(self): + """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: + success = self._client.set_mode(self._client.MODE_HEAT) + elif operation_mode == STATE_COOL: + success = self._client.set_mode(self._client.MODE_COOL) + elif operation_mode == STATE_AUTO: + success = self._client.set_mode(self._client.MODE_AUTO) + else: + success = self._client.set_mode(self._client.MODE_OFF) + + if not success: + _LOGGER.error("Failed to change the operation mode") + return success + + def set_temperature(self, **kwargs): + """Set a new target temperature.""" + set_temp = True + operation_mode = kwargs.get(ATTR_OPERATION_MODE, self._client.mode) + temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temperature = kwargs.get(ATTR_TEMPERATURE) + + if operation_mode != self._client.mode: + set_temp = self._set_operation_mode(operation_mode) + + if set_temp: + if operation_mode == self._client.MODE_HEAT: + success = self._client.set_setpoints( + temperature, self._client.cooltemp) + elif operation_mode == self._client.MODE_COOL: + success = self._client.set_setpoints( + self._client.heattemp, temperature) + elif operation_mode == self._client.MODE_AUTO: + success = self._client.set_setpoints(temp_low, temp_high) + else: + _LOGGER.error("The thermostat is currently not in a mode " + "that supports target temperature") + + if not success: + _LOGGER.error("Failed to change the temperature") + + def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + if fan_mode == STATE_ON: + success = self._client.set_fan(self._client.FAN_ON) + else: + success = self._client.set_fan(self._client.FAN_AUTO) + + if not success: + _LOGGER.error("Failed to change the fan mode") + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + self._set_operation_mode(operation_mode) + + def set_humidity(self, humidity): + """Set new target humidity.""" + success = self._client.set_hum_setpoint(humidity) + + 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/vera.py b/homeassistant/components/climate/vera.py index 06325ae05619e..6fb6bc0ff4841 100644 --- a/homeassistant/components/climate/vera.py +++ b/homeassistant/components/climate/vera.py @@ -7,7 +7,9 @@ import logging from homeassistant.util import convert -from homeassistant.components.climate import ClimateDevice, ENTITY_ID_FORMAT +from homeassistant.components.climate import ( + ClimateDevice, ENTITY_ID_FORMAT, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_OPERATION_MODE, SUPPORT_FAN_MODE) from homeassistant.const import ( TEMP_FAHRENHEIT, TEMP_CELSIUS, @@ -23,12 +25,15 @@ OPERATION_LIST = ['Heat', 'Cool', 'Auto Changeover', 'Off'] FAN_OPERATION_LIST = ['On', 'Auto', 'Cycle'] +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | + SUPPORT_FAN_MODE) + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up of Vera thermostats.""" add_devices_callback( - VeraThermostat(device, VERA_CONTROLLER) for - device in VERA_DEVICES['climate']) + VeraThermostat(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['climate']) class VeraThermostat(VeraDevice, ClimateDevice): @@ -39,6 +44,11 @@ def __init__(self, vera_device, controller): VeraDevice.__init__(self, vera_device, controller) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" @@ -75,13 +85,13 @@ def fan_list(self): """Return a list of available fan modes.""" return FAN_OPERATION_LIST - def set_fan_mode(self, mode): + def set_fan_mode(self, fan_mode): """Set new target temperature.""" - if mode == FAN_OPERATION_LIST[0]: + if fan_mode == FAN_OPERATION_LIST[0]: self.vera_device.fan_on() - elif mode == FAN_OPERATION_LIST[1]: + elif fan_mode == FAN_OPERATION_LIST[1]: self.vera_device.fan_auto() - elif mode == FAN_OPERATION_LIST[2]: + elif fan_mode == FAN_OPERATION_LIST[2]: return self.vera_device.fan_cycle() @property diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index f52340dc62730..c67e032c14947 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -1,62 +1,101 @@ """ -Support for Wink thermostats. +Support for Wink thermostats, Air Conditioners, and Water Heaters. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.wink/ """ import asyncio +import logging -from homeassistant.components.wink import WinkDevice, DOMAIN from homeassistant.components.climate import ( - STATE_AUTO, STATE_COOL, STATE_HEAT, ClimateDevice, - ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, - ATTR_CURRENT_HUMIDITY) + ATTR_CURRENT_HUMIDITY, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, STATE_AUTO, STATE_COOL, STATE_ECO, STATE_ELECTRIC, + STATE_FAN_ONLY, STATE_GAS, STATE_HEAT, STATE_HEAT_PUMP, STATE_HIGH_DEMAND, + STATE_PERFORMANCE, SUPPORT_AUX_HEAT, SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW, + ClimateDevice) +from homeassistant.components.wink import DOMAIN, WinkDevice from homeassistant.const import ( - TEMP_CELSIUS, STATE_ON, - STATE_OFF, STATE_UNKNOWN) + PRECISION_TENTHS, STATE_OFF, STATE_ON, STATE_UNKNOWN, TEMP_CELSIUS) +from homeassistant.helpers.temperature import display_temp as show_temp + +_LOGGER = logging.getLogger(__name__) + +ATTR_ECO_TARGET = 'eco_target' +ATTR_EXTERNAL_TEMPERATURE = 'external_temperature' +ATTR_OCCUPIED = 'occupied' +ATTR_RHEEM_TYPE = 'rheem_type' +ATTR_SCHEDULE_ENABLED = 'schedule_enabled' +ATTR_SMART_TEMPERATURE = 'smart_temperature' +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_VACATION_MODE = 'vacation_mode' +ATTR_HEAT_ON = 'heat_on' +ATTR_COOL_ON = 'cool_on' DEPENDENCIES = ['wink'] -STATE_AUX = 'aux' -STATE_ECO = 'eco' -STATE_FAN = 'fan' SPEED_LOW = 'low' SPEED_MEDIUM = 'medium' SPEED_HIGH = 'high' -ATTR_EXTERNAL_TEMPERATURE = "external_temperature" -ATTR_SMART_TEMPERATURE = "smart_temperature" -ATTR_ECO_TARGET = "eco_target" -ATTR_OCCUPIED = "occupied" +HA_STATE_TO_WINK = { + STATE_AUTO: 'auto', + STATE_COOL: 'cool_only', + STATE_ECO: 'eco', + STATE_ELECTRIC: 'electric_only', + STATE_FAN_ONLY: 'fan_only', + STATE_GAS: 'gas', + STATE_HEAT: 'heat_only', + STATE_HEAT_PUMP: 'heat_pump', + STATE_HIGH_DEMAND: 'high_demand', + STATE_OFF: 'off', + STATE_PERFORMANCE: 'performance', +} + +WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()} + +SUPPORT_FLAGS_THERMOSTAT = ( + SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_HIGH | + SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_OPERATION_MODE | + SUPPORT_AWAY_MODE | SUPPORT_FAN_MODE | SUPPORT_AUX_HEAT) + +SUPPORT_FLAGS_AC = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | + SUPPORT_FAN_MODE) + +SUPPORT_FLAGS_HEATER = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | + SUPPORT_AWAY_MODE) def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Wink thermostat.""" + """Set up the Wink climate devices.""" import pywink - temp_unit = hass.config.units.temperature_unit for climate in pywink.get_thermostats(): _id = climate.object_id() + climate.name() if _id not in hass.data[DOMAIN]['unique_ids']: - add_devices([WinkThermostat(climate, hass, temp_unit)]) + add_devices([WinkThermostat(climate, hass)]) for climate in pywink.get_air_conditioners(): _id = climate.object_id() + climate.name() if _id not in hass.data[DOMAIN]['unique_ids']: - add_devices([WinkAC(climate, hass, temp_unit)]) + add_devices([WinkAC(climate, hass)]) + for water_heater in pywink.get_water_heaters(): + _id = water_heater.object_id() + water_heater.name() + if _id not in hass.data[DOMAIN]['unique_ids']: + add_devices([WinkWaterHeater(water_heater, hass)]) # pylint: disable=abstract-method class WinkThermostat(WinkDevice, ClimateDevice): """Representation of a Wink thermostat.""" - def __init__(self, wink, hass, temp_unit): - """Initialize the Wink device.""" - super().__init__(wink, hass) - self._config_temp_unit = temp_unit + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS_THERMOSTAT @asyncio.coroutine def async_added_to_hass(self): - """Callback when entity is added to hass.""" + """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['climate'].append(self) @property @@ -67,20 +106,23 @@ def temperature_unit(self): @property def device_state_attributes(self): - """Return the optional state attributes.""" + """Return the optional device state attributes.""" data = {} target_temp_high = self.target_temperature_high target_temp_low = self.target_temperature_low if target_temp_high is not None: - data[ATTR_TARGET_TEMP_HIGH] = self._convert_for_display( - self.target_temperature_high) + data[ATTR_TARGET_TEMP_HIGH] = show_temp( + self.hass, self.target_temperature_high, self.temperature_unit, + PRECISION_TENTHS) if target_temp_low is not None: - data[ATTR_TARGET_TEMP_LOW] = self._convert_for_display( - self.target_temperature_low) + data[ATTR_TARGET_TEMP_LOW] = show_temp( + self.hass, self.target_temperature_low, self.temperature_unit, + PRECISION_TENTHS) if self.external_temperature: - data[ATTR_EXTERNAL_TEMPERATURE] = self._convert_for_display( - self.external_temperature) + data[ATTR_EXTERNAL_TEMPERATURE] = show_temp( + self.hass, self.external_temperature, self.temperature_unit, + PRECISION_TENTHS) if self.smart_temperature: data[ATTR_SMART_TEMPERATURE] = self.smart_temperature @@ -91,6 +133,12 @@ def device_state_attributes(self): if self.eco_target: data[ATTR_ECO_TARGET] = self.eco_target + if self.heat_on: + data[ATTR_HEAT_ON] = self.heat_on + + if self.cool_on: + data[ATTR_COOL_ON] = self.cool_on + current_humidity = self.current_humidity if current_humidity is not None: data[ATTR_CURRENT_HUMIDITY] = current_humidity @@ -126,7 +174,7 @@ def smart_temperature(self): @property def eco_target(self): - """Return status of eco target (Is the termostat in eco mode).""" + """Return status of eco target (Is the thermostat in eco mode).""" return self.wink.eco_target() @property @@ -134,23 +182,27 @@ def occupied(self): """Return status of if the thermostat has detected occupancy.""" return self.wink.occupied() + @property + def heat_on(self): + """Return whether or not the heat is actually heating.""" + return self.wink.heat_on() + + @property + def cool_on(self): + """Return whether or not the heat is actually heating.""" + return self.wink.cool_on() + @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" if not self.wink.is_on(): current_op = STATE_OFF - elif self.wink.current_hvac_mode() == 'cool_only': - current_op = STATE_COOL - elif self.wink.current_hvac_mode() == 'heat_only': - current_op = STATE_HEAT - elif self.wink.current_hvac_mode() == 'aux': - current_op = STATE_HEAT - elif self.wink.current_hvac_mode() == 'auto': - current_op = STATE_AUTO - elif self.wink.current_hvac_mode() == 'eco': - current_op = STATE_ECO else: - current_op = STATE_UNKNOWN + current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode()) + if current_op == 'aux': + return STATE_HEAT + if current_op is None: + current_op = STATE_UNKNOWN return current_op @property @@ -199,11 +251,12 @@ def is_away_mode_on(self): @property def is_aux_heat_on(self): """Return true if aux heater.""" - if self.wink.current_hvac_mode() == 'aux' and self.wink.is_on(): + if 'aux' not in self.wink.hvac_modes(): + return None + + if self.wink.current_hvac_mode() == 'aux': return True - elif self.wink.current_hvac_mode() == 'aux' and not self.wink.is_on(): - return False - return None + return False def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -223,32 +276,27 @@ def set_temperature(self, **kwargs): def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_HEAT: - self.wink.set_operation_mode('heat_only') - elif operation_mode == STATE_COOL: - self.wink.set_operation_mode('cool_only') - elif operation_mode == STATE_AUTO: - self.wink.set_operation_mode('auto') - elif operation_mode == STATE_OFF: - self.wink.set_operation_mode('off') - elif operation_mode == STATE_AUX: - self.wink.set_operation_mode('aux') - elif operation_mode == STATE_ECO: - self.wink.set_operation_mode('eco') + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + # The only way to disable aux heat is with the toggle + if self.is_aux_heat_on and op_mode_to_set == STATE_HEAT: + return + self.wink.set_operation_mode(op_mode_to_set) @property def operation_list(self): """List of available operation modes.""" op_list = ['off'] modes = self.wink.hvac_modes() - if 'cool_only' in modes: - op_list.append(STATE_COOL) - if 'heat_only' in modes or 'aux' in modes: - op_list.append(STATE_HEAT) - if 'auto' in modes: - op_list.append(STATE_AUTO) - if 'eco' in modes: - op_list.append(STATE_ECO) + for mode in modes: + if mode == 'aux': + continue + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invalid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) return op_list def turn_away_mode_on(self): @@ -276,17 +324,17 @@ def fan_list(self): return self.wink.fan_modes() return None - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Turn fan on/off.""" - self.wink.set_fan_mode(fan.lower()) + self.wink.set_fan_mode(fan_mode.lower()) def turn_aux_heat_on(self): - """Turn auxillary heater on.""" - self.set_operation_mode(STATE_AUX) + """Turn auxiliary heater on.""" + self.wink.set_operation_mode('aux') def turn_aux_heat_off(self): - """Turn auxillary heater off.""" - self.set_operation_mode(STATE_AUTO) + """Turn auxiliary heater off.""" + self.set_operation_mode(STATE_HEAT) @property def min_temp(self): @@ -294,7 +342,6 @@ def min_temp(self): minimum = 7 # Default minimum min_min = self.wink.min_min_set_point() min_max = self.wink.min_max_set_point() - return_value = minimum if self.current_operation == STATE_HEAT: if min_min: return_value = min_min @@ -320,7 +367,6 @@ def max_temp(self): maximum = 35 # Default maximum max_min = self.wink.max_min_set_point() max_max = self.wink.max_max_set_point() - return_value = maximum if self.current_operation == STATE_HEAT: if max_min: return_value = max_min @@ -344,10 +390,10 @@ def max_temp(self): class WinkAC(WinkDevice, ClimateDevice): """Representation of a Wink air conditioner.""" - def __init__(self, wink, hass, temp_unit): - """Initialize the Wink device.""" - super().__init__(wink, hass) - self._config_temp_unit = temp_unit + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS_AC @property def temperature_unit(self): @@ -357,18 +403,20 @@ def temperature_unit(self): @property def device_state_attributes(self): - """Return the optional state attributes.""" + """Return the optional device state attributes.""" data = {} target_temp_high = self.target_temperature_high target_temp_low = self.target_temperature_low if target_temp_high is not None: - data[ATTR_TARGET_TEMP_HIGH] = self._convert_for_display( - self.target_temperature_high) + data[ATTR_TARGET_TEMP_HIGH] = show_temp( + self.hass, self.target_temperature_high, self.temperature_unit, + PRECISION_TENTHS) if target_temp_low is not None: - data[ATTR_TARGET_TEMP_LOW] = self._convert_for_display( - self.target_temperature_low) - data["total_consumption"] = self.wink.total_consumption() - data["schedule_enabled"] = self.wink.schedule_enabled() + data[ATTR_TARGET_TEMP_LOW] = show_temp( + self.hass, self.target_temperature_low, self.temperature_unit, + PRECISION_TENTHS) + data[ATTR_TOTAL_CONSUMPTION] = self.wink.total_consumption() + data[ATTR_SCHEDULE_ENABLED] = self.wink.schedule_enabled() return data @@ -379,17 +427,16 @@ def current_temperature(self): @property def current_operation(self): - """Return current operation ie. heat, cool, idle.""" + """Return current operation ie. auto_eco, cool_only, fan_only.""" if not self.wink.is_on(): current_op = STATE_OFF - elif self.wink.current_mode() == 'cool_only': - current_op = STATE_COOL - elif self.wink.current_mode() == 'auto_eco': - current_op = STATE_ECO - elif self.wink.current_mode() == 'fan_only': - current_op = STATE_FAN else: - current_op = STATE_UNKNOWN + wink_mode = self.wink.current_mode() + if wink_mode == "auto_eco": + wink_mode = "eco" + current_op = WINK_STATE_TO_HA.get(wink_mode) + if current_op is None: + current_op = STATE_UNKNOWN return current_op @property @@ -397,12 +444,16 @@ def operation_list(self): """List of available operation modes.""" op_list = ['off'] modes = self.wink.modes() - if 'cool_only' in modes: - op_list.append(STATE_COOL) - if 'auto_eco' in modes: - op_list.append(STATE_ECO) - if 'fan_only' in modes: - op_list.append(STATE_FAN) + for mode in modes: + if mode == "auto_eco": + mode = "eco" + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invalid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) return op_list def set_temperature(self, **kwargs): @@ -412,53 +463,137 @@ def set_temperature(self, **kwargs): def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_COOL: - self.wink.set_operation_mode('cool_only') - elif operation_mode == STATE_ECO: - self.wink.set_operation_mode('auto_eco') - elif operation_mode == STATE_OFF: - self.wink.set_operation_mode('off') - elif operation_mode == STATE_FAN: - self.wink.set_operation_mode('fan_only') + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + if op_mode_to_set == 'eco': + op_mode_to_set = 'auto_eco' + self.wink.set_operation_mode(op_mode_to_set) @property def target_temperature(self): """Return the temperature we try to reach.""" return self.wink.current_max_set_point() - @property - def target_temperature_low(self): - """Only supports cool.""" - return None - - @property - def target_temperature_high(self): - """Only supports cool.""" - return None - @property def current_fan_mode(self): - """Return the current fan mode.""" + """ + Return the current fan mode. + + The official Wink app only supports 3 modes [low, medium, high] + which are equal to [0.33, 0.66, 1.0] respectively. + """ speed = self.wink.current_fan_speed() - if speed <= 0.4 and speed > 0.3: + if speed <= 0.33: return SPEED_LOW - elif speed <= 0.8 and speed > 0.5: + elif speed <= 0.66: return SPEED_MEDIUM - elif speed <= 1.0 and speed > 0.8: - return SPEED_HIGH - return STATE_UNKNOWN + return SPEED_HIGH @property def fan_list(self): """Return a list of available fan modes.""" return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def set_fan_mode(self, mode): - """Set fan speed.""" - if mode == SPEED_LOW: - speed = 0.4 - elif mode == SPEED_MEDIUM: - speed = 0.8 - elif mode == SPEED_HIGH: + def set_fan_mode(self, fan_mode): + """ + Set fan speed. + + The official Wink app only supports 3 modes [low, medium, high] + which are equal to [0.33, 0.66, 1.0] respectively. + """ + if fan_mode == SPEED_LOW: + speed = 0.33 + elif fan_mode == SPEED_MEDIUM: + speed = 0.66 + elif fan_mode == SPEED_HIGH: speed = 1.0 self.wink.set_ac_fan_speed(speed) + + +class WinkWaterHeater(WinkDevice, ClimateDevice): + """Representation of a Wink water heater.""" + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS_HEATER + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + # The Wink API always returns temp in Celsius + return TEMP_CELSIUS + + @property + def device_state_attributes(self): + """Return the optional device state attributes.""" + data = {} + data[ATTR_VACATION_MODE] = self.wink.vacation_mode_enabled() + data[ATTR_RHEEM_TYPE] = self.wink.rheem_type() + + return data + + @property + def current_operation(self): + """ + Return current operation one of the following. + + ["eco", "performance", "heat_pump", + "high_demand", "electric_only", "gas] + """ + if not self.wink.is_on(): + current_op = STATE_OFF + else: + current_op = WINK_STATE_TO_HA.get(self.wink.current_mode()) + if current_op is None: + current_op = STATE_UNKNOWN + return current_op + + @property + def operation_list(self): + """List of available operation modes.""" + op_list = ['off'] + modes = self.wink.modes() + for mode in modes: + if mode == 'aux': + continue + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invalid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) + return op_list + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + self.wink.set_temperature(target_temp) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + self.wink.set_operation_mode(op_mode_to_set) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.wink.current_set_point() + + def turn_away_mode_on(self): + """Turn away on.""" + self.wink.set_vacation_mode(True) + + def turn_away_mode_off(self): + """Turn away off.""" + self.wink.set_vacation_mode(False) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self.wink.min_set_point() + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.wink.max_set_point() diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py old mode 100755 new mode 100644 index 497916a3e4d00..1eec9c82f3ca9 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -7,8 +7,9 @@ # Because we do not compile openzwave on CI # pylint: disable=import-error import logging -from homeassistant.components.climate import DOMAIN -from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate import ( + DOMAIN, ClimateDevice, SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, + SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE) from homeassistant.components.zwave import ZWaveDeviceEntity from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import from homeassistant.const import ( @@ -70,6 +71,18 @@ def __init__(self, values, temp_unit): self._zxt_120 = 1 self.update_properties() + @property + def supported_features(self): + """Return the list of supported features.""" + support = SUPPORT_TARGET_TEMPERATURE + if self.values.fan_mode: + support |= SUPPORT_FAN_MODE + if self.values.mode: + support |= SUPPORT_OPERATION_MODE + if self._zxt_120 == 1 and self.values.zxt_120_swing_mode: + support |= SUPPORT_SWING_MODE + return support + def update_properties(self): """Handle the data changes for node values.""" # Operation Mode @@ -185,10 +198,10 @@ def set_temperature(self, **kwargs): self.values.primary.data = temperature - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target fan mode.""" if self.values.fan_mode: - self.values.fan_mode.data = fan + self.values.fan_mode.data = fan_mode def set_operation_mode(self, operation_mode): """Set new target operation mode.""" diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 8804f6d113fab..8c1a9751c1957 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,49 +1,309 @@ -"""Component to integrate the Home Assistant cloud.""" +""" +Component to integrate the Home Assistant cloud. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/cloud/ +""" import asyncio +from datetime import datetime +import json import logging +import os +import aiohttp +import async_timeout import voluptuous as vol -from . import http_api, cloud_api -from .const import DOMAIN +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME) +from homeassistant.helpers import entityfilter, config_validation as cv +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 +REQUIREMENTS = ['warrant==0.6.1'] + +_LOGGER = logging.getLogger(__name__) +CONF_ALEXA = 'alexa' +CONF_ALIASES = 'aliases' +CONF_COGNITO_CLIENT_ID = 'cognito_client_id' +CONF_ENTITY_CONFIG = 'entity_config' +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'] -CONF_MODE = 'mode' + MODE_DEV = 'development' -MODE_STAGING = 'staging' -MODE_PRODUCTION = 'production' -DEFAULT_MODE = MODE_DEV + +ALEXA_ENTITY_SCHEMA = vol.Schema({ + vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string, + vol.Optional(alexa_sh.CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(alexa_sh.CONF_NAME): cv.string, +}) + +GOOGLE_ENTITY_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME): 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({ + vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, +}) + +ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA} +}) + +GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA} +}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): - vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]), + vol.In([MODE_DEV] + list(SERVERS)), + # Change to optional when we include real servers + vol.Optional(CONF_COGNITO_CLIENT_ID): str, + 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, }), }, extra=vol.ALLOW_EXTRA) -_LOGGER = logging.getLogger(__name__) @asyncio.coroutine def async_setup(hass, config): """Initialize the Home Assistant cloud.""" - mode = MODE_PRODUCTION - if DOMAIN in config: - mode = config[DOMAIN].get(CONF_MODE) - - if mode != 'development': - _LOGGER.error('Only development mode is currently allowed.') - return False + kwargs = dict(config[DOMAIN]) + else: + kwargs = {CONF_MODE: DEFAULT_MODE} - data = hass.data[DOMAIN] = { - 'mode': mode - } + alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({}) - cloud = yield from cloud_api.async_load_auth(hass) + if CONF_GOOGLE_ACTIONS not in kwargs: + kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({}) - if cloud is not None: - data['cloud'] = cloud + kwargs[CONF_ALEXA] = alexa_sh.Config( + should_expose=alexa_conf[CONF_FILTER], + entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), + ) + cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, cloud.async_start) yield from http_api.async_setup(hass) return True + + +class Cloud: + """Store the configuration of the cloud connection.""" + + def __init__(self, hass, mode, alexa, google_actions, + cognito_client_id=None, user_pool_id=None, region=None, + relayer=None, google_actions_sync_url=None): + """Create an instance of Cloud.""" + self.hass = hass + self.mode = mode + self.alexa_config = alexa + self._google_actions = google_actions + self._gactions_config = None + self.jwt_keyset = None + self.id_token = None + self.access_token = None + self.refresh_token = None + self.iot = iot.CloudIoT(self) + + if mode == MODE_DEV: + self.cognito_client_id = cognito_client_id + self.user_pool_id = user_pool_id + self.region = region + self.relayer = relayer + self.google_actions_sync_url = google_actions_sync_url + + else: + info = SERVERS[mode] + + self.cognito_client_id = info['cognito_client_id'] + self.user_pool_id = info['user_pool_id'] + self.region = info['region'] + self.relayer = info['relayer'] + self.google_actions_sync_url = info['google_actions_sync_url'] + + @property + def is_logged_in(self): + """Get if cloud is logged in.""" + return self.id_token is not None + + @property + def subscription_expired(self): + """Return a boolean if the subscription has expired.""" + return dt_util.utcnow() > self.expiration_date + + @property + def expiration_date(self): + """Return the subscription expiration as a UTC datetime object.""" + return datetime.combine( + dt_util.parse_date(self.claims['custom:sub-exp']), + datetime.min.time()).replace(tzinfo=dt_util.UTC) + + @property + def claims(self): + """Return the claims from the id token.""" + return self._decode_claims(self.id_token) + + @property + def user_info_path(self): + """Get path to the stored auth.""" + return self.path('{}_auth.json'.format(self.mode)) + + @property + def gactions_config(self): + """Return the Google Assistant config.""" + if self._gactions_config is None: + conf = self._google_actions + + def should_expose(entity): + """If an entity should be exposed.""" + return conf['filter'](entity.entity_id) + + self._gactions_config = ga_h.Config( + should_expose=should_expose, + agent_user_id=self.claims['cognito:username'], + entity_config=conf.get(CONF_ENTITY_CONFIG), + ) + + return self._gactions_config + + def path(self, *parts): + """Get config path inside cloud dir. + + Async friendly. + """ + return self.hass.config.path(CONFIG_DIR, *parts) + + @asyncio.coroutine + def logout(self): + """Close connection and remove all credentials.""" + yield from self.iot.disconnect() + + self.id_token = None + self.access_token = None + self.refresh_token = None + self._gactions_config = None + + yield from self.hass.async_add_job( + lambda: os.remove(self.user_info_path)) + + def write_user_info(self): + """Write user info to a file.""" + with open(self.user_info_path, 'wt') as file: + file.write(json.dumps({ + 'id_token': self.id_token, + 'access_token': self.access_token, + 'refresh_token': self.refresh_token, + }, indent=4)) + + @asyncio.coroutine + def async_start(self, _): + """Start the cloud component.""" + success = yield from self._fetch_jwt_keyset() + + # Fetching keyset can fail if internet is not up yet. + if not success: + self.hass.helpers.event.async_call_later(5, self.async_start) + return + + def load_config(): + """Load config.""" + # Ensure config dir exists + path = self.hass.config.path(CONFIG_DIR) + if not os.path.isdir(path): + os.mkdir(path) + + user_info = self.user_info_path + if not os.path.isfile(user_info): + return None + + with open(user_info, 'rt') as file: + return json.loads(file.read()) + + info = yield from self.hass.async_add_job(load_config) + + if info is None: + return + + # Validate tokens + try: + for token in 'id_token', 'access_token': + self._decode_claims(info[token]) + except ValueError as err: # Raised when token is invalid + _LOGGER.warning("Found invalid token %s: %s", token, err) + return + + self.id_token = info['id_token'] + self.access_token = info['access_token'] + self.refresh_token = info['refresh_token'] + + self.hass.add_job(self.iot.connect()) + + @asyncio.coroutine + def _fetch_jwt_keyset(self): + """Fetch the JWT keyset for the Cognito instance.""" + session = async_get_clientsession(self.hass) + url = ("https://cognito-idp.us-east-1.amazonaws.com/" + "{}/.well-known/jwks.json".format(self.user_pool_id)) + + try: + with async_timeout.timeout(10, loop=self.hass.loop): + req = yield from session.get(url) + self.jwt_keyset = yield from req.json() + + return True + + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + _LOGGER.error("Error fetching Cognito keyset: %s", err) + return False + + def _decode_claims(self, token): + """Decode the claims in a token.""" + from jose import jwt, exceptions as jose_exceptions + try: + header = jwt.get_unverified_header(token) + except jose_exceptions.JWTError as err: + raise ValueError(str(err)) from None + kid = header.get('kid') + + if kid is None: + raise ValueError("No kid in header") + + # Locate the key for this kid + key = None + for key_dict in self.jwt_keyset['keys']: + if key_dict['kid'] == kid: + key = key_dict + break + if not key: + raise ValueError( + "Unable to locate kid ({}) in keyset".format(kid)) + + try: + return jwt.decode( + token, key, audience=self.cognito_client_id, options={ + 'verify_exp': False, + }) + except jose_exceptions.JWTError as err: + raise ValueError(str(err)) from None diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py new file mode 100644 index 0000000000000..dcf7567482a87 --- /dev/null +++ b/homeassistant/components/cloud/auth_api.py @@ -0,0 +1,155 @@ +"""Package to communicate with the authentication API.""" + + +class CloudError(Exception): + """Base class for cloud related errors.""" + + +class Unauthenticated(CloudError): + """Raised when authentication failed.""" + + +class UserNotFound(CloudError): + """Raised when a user is not found.""" + + +class UserNotConfirmed(CloudError): + """Raised when a user has not confirmed email yet.""" + + +class PasswordChangeRequired(CloudError): + """Raised when a password change is required.""" + + # https://github.com/PyCQA/pylint/issues/1085 + # pylint: disable=useless-super-delegation + def __init__(self, message='Password change required.'): + """Initialize a password change required error.""" + super().__init__(message) + + +class UnknownError(CloudError): + """Raised when an unknown error occurs.""" + + +AWS_EXCEPTIONS = { + 'UserNotFoundException': UserNotFound, + 'NotAuthorizedException': Unauthenticated, + 'UserNotConfirmedException': UserNotConfirmed, + 'PasswordResetRequiredException': PasswordChangeRequired, +} + + +def _map_aws_exception(err): + """Map AWS exception to our exceptions.""" + ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError) + return ex(err.response['Error']['Message']) + + +def register(cloud, email, password): + """Register a new account.""" + from botocore.exceptions import ClientError + + cognito = _cognito(cloud) + # Workaround for bug in Warrant. PR with fix: + # https://github.com/capless/warrant/pull/82 + cognito.add_base_attributes() + try: + cognito.register(email, password) + except ClientError as err: + raise _map_aws_exception(err) + + +def resend_email_confirm(cloud, email): + """Resend email confirmation.""" + from botocore.exceptions import ClientError + + cognito = _cognito(cloud, username=email) + + try: + cognito.client.resend_confirmation_code( + Username=email, + ClientId=cognito.client_id + ) + except ClientError as err: + raise _map_aws_exception(err) + + +def forgot_password(cloud, email): + """Initialize forgotten password flow.""" + from botocore.exceptions import ClientError + + cognito = _cognito(cloud, username=email) + + try: + cognito.initiate_forgot_password() + except ClientError as err: + raise _map_aws_exception(err) + + +def login(cloud, email, password): + """Log user in and fetch certificate.""" + cognito = _authenticate(cloud, email, password) + cloud.id_token = cognito.id_token + cloud.access_token = cognito.access_token + cloud.refresh_token = cognito.refresh_token + cloud.write_user_info() + + +def check_token(cloud): + """Check that the token is valid and verify if needed.""" + from botocore.exceptions import ClientError + + cognito = _cognito( + cloud, + access_token=cloud.access_token, + refresh_token=cloud.refresh_token) + + try: + if cognito.check_token(): + cloud.id_token = cognito.id_token + cloud.access_token = cognito.access_token + cloud.write_user_info() + except ClientError as err: + raise _map_aws_exception(err) + + +def _authenticate(cloud, email, password): + """Log in and return an authenticated Cognito instance.""" + from botocore.exceptions import ClientError + from warrant.exceptions import ForceChangePasswordException + + assert not cloud.is_logged_in, 'Cannot login if already logged in.' + + cognito = _cognito(cloud, username=email) + + try: + cognito.authenticate(password=password) + return cognito + + except ForceChangePasswordException as err: + raise PasswordChangeRequired + + except ClientError as err: + raise _map_aws_exception(err) + + +def _cognito(cloud, **kwargs): + """Get the client credentials.""" + import botocore + import boto3 + from warrant import Cognito + + cognito = Cognito( + user_pool_id=cloud.user_pool_id, + client_id=cloud.cognito_client_id, + user_pool_region=cloud.region, + **kwargs + ) + cognito.client = boto3.client( + 'cognito-idp', + region_name=cloud.region, + config=botocore.config.Config( + signature_version=botocore.UNSIGNED + ) + ) + return cognito diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py deleted file mode 100644 index 6429da145167d..0000000000000 --- a/homeassistant/components/cloud/cloud_api.py +++ /dev/null @@ -1,297 +0,0 @@ -"""Package to offer tools to communicate with the cloud.""" -import asyncio -from datetime import timedelta -import json -import logging -import os -from urllib.parse import urljoin - -import aiohttp -import async_timeout - -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util.dt import utcnow - -from .const import AUTH_FILE, REQUEST_TIMEOUT, SERVERS -from .util import get_mode - -_LOGGER = logging.getLogger(__name__) - - -URL_CREATE_TOKEN = 'o/token/' -URL_REVOKE_TOKEN = 'o/revoke_token/' -URL_ACCOUNT = 'account.json' - - -class CloudError(Exception): - """Base class for cloud related errors.""" - - def __init__(self, reason=None, status=None): - """Initialize a cloud error.""" - super().__init__(reason) - self.status = status - - -class Unauthenticated(CloudError): - """Raised when authentication failed.""" - - -class UnknownError(CloudError): - """Raised when an unknown error occurred.""" - - -@asyncio.coroutine -def async_load_auth(hass): - """Load authentication from disk and verify it.""" - auth = yield from hass.async_add_job(_read_auth, hass) - - if not auth: - return None - - cloud = Cloud(hass, auth) - - try: - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - auth_check = yield from cloud.async_refresh_account_info() - - if not auth_check: - _LOGGER.error('Unable to validate credentials.') - return None - - return cloud - - except asyncio.TimeoutError: - _LOGGER.error('Unable to reach server to validate credentials.') - return None - - -@asyncio.coroutine -def async_login(hass, username, password, scope=None): - """Get a token using a username and password. - - Returns a coroutine. - """ - data = { - 'grant_type': 'password', - 'username': username, - 'password': password - } - if scope is not None: - data['scope'] = scope - - auth = yield from _async_get_token(hass, data) - - yield from hass.async_add_job(_write_auth, hass, auth) - - return Cloud(hass, auth) - - -@asyncio.coroutine -def _async_get_token(hass, data): - """Get a new token and return it as a dictionary. - - Raises exceptions when errors occur: - - Unauthenticated - - UnknownError - """ - session = async_get_clientsession(hass) - auth = aiohttp.BasicAuth(*_client_credentials(hass)) - - try: - req = yield from session.post( - _url(hass, URL_CREATE_TOKEN), - data=data, - auth=auth - ) - - if req.status == 401: - _LOGGER.error('Cloud login failed: %d', req.status) - raise Unauthenticated(status=req.status) - elif req.status != 200: - _LOGGER.error('Cloud login failed: %d', req.status) - raise UnknownError(status=req.status) - - response = yield from req.json() - response['expires_at'] = \ - (utcnow() + timedelta(seconds=response['expires_in'])).isoformat() - - return response - - except aiohttp.ClientError: - raise UnknownError() - - -class Cloud: - """Store Hass Cloud info.""" - - def __init__(self, hass, auth): - """Initialize Hass cloud info object.""" - self.hass = hass - self.auth = auth - self.account = None - - @property - def access_token(self): - """Return access token.""" - return self.auth['access_token'] - - @property - def refresh_token(self): - """Get refresh token.""" - return self.auth['refresh_token'] - - @asyncio.coroutine - def async_refresh_account_info(self): - """Refresh the account info.""" - req = yield from self.async_request('get', URL_ACCOUNT) - - if req.status != 200: - return False - - self.account = yield from req.json() - return True - - @asyncio.coroutine - def async_refresh_access_token(self): - """Get a token using a refresh token.""" - try: - self.auth = yield from _async_get_token(self.hass, { - 'grant_type': 'refresh_token', - 'refresh_token': self.refresh_token, - }) - - yield from self.hass.async_add_job( - _write_auth, self.hass, self.auth) - - return True - except CloudError: - return False - - @asyncio.coroutine - def async_revoke_access_token(self): - """Revoke active access token.""" - session = async_get_clientsession(self.hass) - client_id, client_secret = _client_credentials(self.hass) - data = { - 'token': self.access_token, - 'client_id': client_id, - 'client_secret': client_secret - } - try: - req = yield from session.post( - _url(self.hass, URL_REVOKE_TOKEN), - data=data, - ) - - if req.status != 200: - _LOGGER.error('Cloud logout failed: %d', req.status) - raise UnknownError(status=req.status) - - self.auth = None - yield from self.hass.async_add_job( - _write_auth, self.hass, None) - - except aiohttp.ClientError: - raise UnknownError() - - @asyncio.coroutine - def async_request(self, method, path, **kwargs): - """Make a request to Home Assistant cloud. - - Will refresh the token if necessary. - """ - session = async_get_clientsession(self.hass) - url = _url(self.hass, path) - - if 'headers' not in kwargs: - kwargs['headers'] = {} - - kwargs['headers']['authorization'] = \ - 'Bearer {}'.format(self.access_token) - - request = yield from session.request(method, url, **kwargs) - - if request.status != 403: - return request - - # Maybe token expired. Try refreshing it. - reauth = yield from self.async_refresh_access_token() - - if not reauth: - return request - - # Release old connection back to the pool. - yield from request.release() - - kwargs['headers']['authorization'] = \ - 'Bearer {}'.format(self.access_token) - - # If we are not already fetching the account info, - # refresh the account info. - - if path != URL_ACCOUNT: - yield from self.async_refresh_account_info() - - request = yield from session.request(method, url, **kwargs) - - return request - - -def _read_auth(hass): - """Read auth file.""" - path = hass.config.path(AUTH_FILE) - - if not os.path.isfile(path): - return None - - with open(path) as file: - return json.load(file).get(get_mode(hass)) - - -def _write_auth(hass, data): - """Write auth info for specified mode. - - Pass in None for data to remove authentication for that mode. - """ - path = hass.config.path(AUTH_FILE) - mode = get_mode(hass) - - if os.path.isfile(path): - with open(path) as file: - content = json.load(file) - else: - content = {} - - if data is None: - content.pop(mode, None) - else: - content[mode] = data - - with open(path, 'wt') as file: - file.write(json.dumps(content, indent=4, sort_keys=True)) - - -def _client_credentials(hass): - """Get the client credentials. - - Async friendly. - """ - mode = get_mode(hass) - - if mode not in SERVERS: - raise ValueError('Mode {} is not supported.'.format(mode)) - - return SERVERS[mode]['client_id'], SERVERS[mode]['client_secret'] - - -def _url(hass, path): - """Generate a url for the cloud. - - Async friendly. - """ - mode = get_mode(hass) - - if mode not in SERVERS: - raise ValueError('Mode {} is not supported.'.format(mode)) - - return urljoin(SERVERS[mode]['host'], path) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index f55a4be21a2ff..82128206d47cb 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,14 +1,26 @@ """Constants for the cloud component.""" DOMAIN = 'cloud' +CONFIG_DIR = '.cloud' REQUEST_TIMEOUT = 10 -AUTH_FILE = '.cloud' SERVERS = { - 'development': { - 'host': 'http://localhost:8000', - 'client_id': 'HBhQxeV8H4aFBcs7jrZUeeDud0FjGEJJSZ9G6gNu', - 'client_secret': ('V1qw2NhB32cSAlP7DOezjgWNgn7ZKgq0jvVZoYSI0KCmg9rg7q4' - 'BSzoebnQnX6tuHCJiZjm2479mZmmtf2LOUdnSqOqkSpjc3js7Wu' - 'VBJrRyfgTVd43kbrEQtuOiaUpK') + 'production': { + 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', + 'user_pool_id': 'us-east-1_87ll5WOP8', + 'region': 'us-east-1', + 'relayer': 'wss://cloud.hass.io:8000/websocket', + 'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.' + 'amazonaws.com/prod/smart_home_sync'), } } + +MESSAGE_EXPIRATION = """ +It looks like your Home Assistant Cloud subscription has expired. Please check +your [account page](/config/cloud/account) to continue using the service. +""" + +MESSAGE_AUTH_FAIL = """ +You have been logged out of Home Assistant Cloud because we have been unable +to verify your credentials. Please [log in](/config/cloud) again to continue +using the service. +""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 661cc8a7ba1d7..a4b3b59f3331f 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,77 +1,110 @@ """The HTTP api to control the cloud integration.""" import asyncio +from functools import wraps import logging -import voluptuous as vol import async_timeout +import voluptuous as vol from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import ( + RequestDataValidator) -from . import cloud_api +from . import auth_api from .const import DOMAIN, REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def async_setup(hass): - """Initialize the HTTP api.""" +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) + hass.http.register_view(CloudRegisterView) + hass.http.register_view(CloudResendConfirmView) + hass.http.register_view(CloudForgotPasswordView) -class CloudLoginView(HomeAssistantView): - """Login to Home Assistant cloud.""" +_CLOUD_ERRORS = { + auth_api.UserNotFound: (400, "User does not exist."), + auth_api.UserNotConfirmed: (400, 'Email not confirmed.'), + auth_api.Unauthenticated: (401, 'Authentication failed.'), + auth_api.PasswordChangeRequired: (400, 'Password change required.'), + asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.') +} - url = '/api/cloud/login' - name = 'api:cloud:login' - schema = vol.Schema({ - vol.Required('username'): str, - vol.Required('password'): str, - }) - @asyncio.coroutine - def post(self, request): - """Validate config and return results.""" +def _handle_cloud_errors(handler): + """Handle auth errors.""" + @wraps(handler) + async def error_handler(view, request, *args, **kwargs): + """Handle exceptions that raise from the wrapped request handler.""" try: - data = yield from request.json() - except ValueError: - _LOGGER.error('Login with invalid JSON') - return self.json_message('Invalid JSON.', 400) + result = await handler(view, request, *args, **kwargs) + return result + + except (auth_api.CloudError, asyncio.TimeoutError) as err: + err_info = _CLOUD_ERRORS.get(err.__class__) + if err_info is None: + err_info = (502, 'Unexpected error: {}'.format(err)) + status, msg = err_info + return view.json_message(msg, status_code=status, + message_code=err.__class__.__name__) + + return error_handler - try: - self.schema(data) - except vol.Invalid as err: - _LOGGER.error('Login with invalid formatted data') - return self.json_message( - 'Message format incorrect: {}'.format(err), 400) +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'] - phase = 1 - try: - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - cloud = yield from cloud_api.async_login( - hass, data['username'], data['password']) + cloud = hass.data[DOMAIN] + websession = hass.helpers.aiohttp_client.async_get_clientsession() - phase += 1 + 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): - yield from cloud.async_refresh_account_info() + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + req = await websession.post( + cloud.google_actions_sync_url, headers={ + 'authorization': cloud.id_token + }) - except cloud_api.Unauthenticated: - return self.json_message( - 'Authentication failed (phase {}).'.format(phase), 401) - except cloud_api.UnknownError: - return self.json_message( - 'Unknown error occurred (phase {}).'.format(phase), 500) - except asyncio.TimeoutError: - return self.json_message( - 'Unable to reach Home Assistant cloud ' - '(phase {}).'.format(phase), 502) + return self.json({}, status_code=req.status) - hass.data[DOMAIN]['cloud'] = cloud - return self.json(cloud.account) + +class CloudLoginView(HomeAssistantView): + """Login to Home Assistant cloud.""" + + url = '/api/cloud/login' + name = 'api:cloud:login' + + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + vol.Required('password'): str, + })) + 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): + 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. + await asyncio.sleep(0, loop=hass.loop) + return self.json(_account_data(cloud)) class CloudLogoutView(HomeAssistantView): @@ -80,40 +113,108 @@ class CloudLogoutView(HomeAssistantView): url = '/api/cloud/logout' name = 'api:cloud:logout' - @asyncio.coroutine - def post(self, request): - """Validate config and return results.""" + @_handle_cloud_errors + async def post(self, request): + """Handle logout request.""" hass = request.app['hass'] - try: - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from \ - hass.data[DOMAIN]['cloud'].async_revoke_access_token() + cloud = hass.data[DOMAIN] - hass.data[DOMAIN].pop('cloud') + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + await cloud.logout() - return self.json({ - 'result': 'ok', - }) - except asyncio.TimeoutError: - return self.json_message("Could not reach the server.", 502) - except cloud_api.UnknownError as err: - return self.json_message( - "Error communicating with the server ({}).".format(err.status), - 502) + return self.json_message('ok') class CloudAccountView(HomeAssistantView): - """Log out of the Home Assistant cloud.""" + """View to retrieve account info.""" url = '/api/cloud/account' name = 'api:cloud:account' - @asyncio.coroutine - def get(self, request): - """Validate config and return results.""" + async def get(self, request): + """Get account info.""" hass = request.app['hass'] + cloud = hass.data[DOMAIN] - if 'cloud' not in hass.data[DOMAIN]: + if not cloud.is_logged_in: return self.json_message('Not logged in', 400) - return self.json(hass.data[DOMAIN]['cloud'].account) + return self.json(_account_data(cloud)) + + +class CloudRegisterView(HomeAssistantView): + """Register on the Home Assistant cloud.""" + + url = '/api/cloud/register' + name = 'api:cloud:register' + + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + vol.Required('password'): vol.All(str, vol.Length(min=6)), + })) + 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): + await hass.async_add_job( + auth_api.register, cloud, data['email'], data['password']) + + return self.json_message('ok') + + +class CloudResendConfirmView(HomeAssistantView): + """Resend email confirmation code.""" + + url = '/api/cloud/resend_confirm' + name = 'api:cloud:resend_confirm' + + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + })) + 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): + await hass.async_add_job( + auth_api.resend_email_confirm, cloud, data['email']) + + return self.json_message('ok') + + +class CloudForgotPasswordView(HomeAssistantView): + """View to start Forgot Password flow..""" + + url = '/api/cloud/forgot_password' + name = 'api:cloud:forgot_password' + + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + })) + 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): + await hass.async_add_job( + auth_api.forgot_password, cloud, data['email']) + + return self.json_message('ok') + + +def _account_data(cloud): + """Generate the auth data JSON response.""" + claims = cloud.claims + + return { + 'email': claims['email'], + 'sub_exp': claims['custom:sub-exp'], + 'cloud': cloud.iot.state, + } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py new file mode 100644 index 0000000000000..7cf8e50e8668a --- /dev/null +++ b/homeassistant/components/cloud/iot.py @@ -0,0 +1,257 @@ +"""Module to handle messages from Home Assistant cloud.""" +import asyncio +import logging +import pprint + +from aiohttp import hdrs, client_exceptions, WSMsgType + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.components.alexa import smart_home as alexa +from homeassistant.components.google_assistant import smart_home as ga +from homeassistant.util.decorator import Registry +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import auth_api +from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL + +HANDLERS = Registry() +_LOGGER = logging.getLogger(__name__) + +STATE_CONNECTING = 'connecting' +STATE_CONNECTED = 'connected' +STATE_DISCONNECTED = 'disconnected' + + +class UnknownHandler(Exception): + """Exception raised when trying to handle unknown handler.""" + + +class CloudIoT: + """Class to manage the IoT connection.""" + + def __init__(self, cloud): + """Initialize the CloudIoT class.""" + self.cloud = cloud + # The WebSocket client + self.client = None + # Scheduled sleep task till next connection retry + self.retry_task = None + # Boolean to indicate if we wanted the connection to close + self.close_requested = False + # The current number of attempts to connect, impacts wait time + self.tries = 0 + # Current state of the connection + self.state = STATE_DISCONNECTED + + @asyncio.coroutine + def connect(self): + """Connect to the IoT broker.""" + if self.state != STATE_DISCONNECTED: + raise RuntimeError('Connect called while not disconnected') + + hass = self.cloud.hass + self.close_requested = False + self.state = STATE_CONNECTING + self.tries = 0 + + @asyncio.coroutine + def _handle_hass_stop(event): + """Handle Home Assistant shutting down.""" + nonlocal remove_hass_stop_listener + remove_hass_stop_listener = None + yield from self.disconnect() + + remove_hass_stop_listener = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _handle_hass_stop) + + while True: + try: + yield from self._handle_connection() + except Exception: # pylint: disable=broad-except + # Safety net. This should never hit. + # Still adding it here to make sure we can always reconnect + _LOGGER.exception("Unexpected error") + + if self.close_requested: + break + + self.state = STATE_CONNECTING + self.tries += 1 + + try: + # Sleep 2^tries seconds between retries + self.retry_task = hass.async_add_job(asyncio.sleep( + 2**min(9, self.tries), loop=hass.loop)) + yield from self.retry_task + self.retry_task = None + except asyncio.CancelledError: + # Happens if disconnect called + break + + self.state = STATE_DISCONNECTED + if remove_hass_stop_listener is not None: + remove_hass_stop_listener() + + @asyncio.coroutine + def _handle_connection(self): + """Connect to the IoT broker.""" + hass = self.cloud.hass + + try: + yield from hass.async_add_job(auth_api.check_token, self.cloud) + except auth_api.Unauthenticated as err: + _LOGGER.error('Unable to refresh token: %s', err) + + hass.components.persistent_notification.async_create( + MESSAGE_AUTH_FAIL, 'Home Assistant Cloud', + 'cloud_subscription_expired') + + # Don't await it because it will cancel this task + hass.async_add_job(self.cloud.logout()) + return + except auth_api.CloudError as err: + _LOGGER.warning("Unable to refresh token: %s", err) + return + + if self.cloud.subscription_expired: + hass.components.persistent_notification.async_create( + MESSAGE_EXPIRATION, 'Home Assistant Cloud', + 'cloud_subscription_expired') + self.close_requested = True + return + + session = async_get_clientsession(self.cloud.hass) + client = None + disconnect_warn = None + + try: + self.client = client = yield from session.ws_connect( + self.cloud.relayer, heartbeat=55, headers={ + hdrs.AUTHORIZATION: + 'Bearer {}'.format(self.cloud.id_token) + }) + self.tries = 0 + + _LOGGER.info("Connected") + self.state = STATE_CONNECTED + + while not client.closed: + msg = yield from client.receive() + + if msg.type in (WSMsgType.CLOSED, WSMsgType.CLOSING): + break + + elif msg.type == WSMsgType.ERROR: + disconnect_warn = 'Connection error' + break + + elif msg.type != WSMsgType.TEXT: + disconnect_warn = 'Received non-Text message: {}'.format( + msg.type) + break + + try: + msg = msg.json() + except ValueError: + disconnect_warn = 'Received invalid JSON.' + break + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Received message:\n%s\n", + pprint.pformat(msg)) + + response = { + 'msgid': msg['msgid'], + } + try: + result = yield from async_handle_message( + hass, self.cloud, msg['handler'], msg['payload']) + + # No response from handler + if result is None: + continue + + response['payload'] = result + + except UnknownHandler: + response['error'] = 'unknown-handler' + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error handling message") + response['error'] = 'exception' + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Publishing message:\n%s\n", + pprint.pformat(response)) + yield from client.send_json(response) + + except client_exceptions.WSServerHandshakeError as err: + if err.code == 401: + disconnect_warn = 'Invalid auth.' + self.close_requested = True + # Should we notify user? + else: + _LOGGER.warning("Unable to connect: %s", err) + + except client_exceptions.ClientError as err: + _LOGGER.warning("Unable to connect: %s", err) + + finally: + if disconnect_warn is None: + _LOGGER.info("Connection closed") + else: + _LOGGER.warning("Connection closed: %s", disconnect_warn) + + @asyncio.coroutine + def disconnect(self): + """Disconnect the client.""" + self.close_requested = True + + if self.client is not None: + yield from self.client.close() + elif self.retry_task is not None: + self.retry_task.cancel() + + +@asyncio.coroutine +def async_handle_message(hass, cloud, handler_name, payload): + """Handle incoming IoT message.""" + handler = HANDLERS.get(handler_name) + + if handler is None: + raise UnknownHandler() + + return (yield from handler(hass, cloud, payload)) + + +@HANDLERS.register('alexa') +@asyncio.coroutine +def async_handle_alexa(hass, cloud, payload): + """Handle an incoming IoT message for Alexa.""" + result = yield from alexa.async_handle_message( + hass, cloud.alexa_config, payload) + return result + + +@HANDLERS.register('google_actions') +@asyncio.coroutine +def async_handle_google_actions(hass, cloud, payload): + """Handle an incoming IoT message for Google Actions.""" + result = yield from ga.async_handle_message( + hass, cloud.gactions_config, payload) + return result + + +@HANDLERS.register('cloud') +@asyncio.coroutine +def async_handle_cloud(hass, cloud, payload): + """Handle an incoming IoT message for cloud component.""" + action = payload['action'] + + if action == 'logout': + yield from cloud.logout() + _LOGGER.error("You have been logged out from Home Assistant cloud: %s", + payload['reason']) + else: + _LOGGER.warning("Received unknown cloud action: %s", action) + + return None diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py deleted file mode 100644 index ec5445f0638c0..0000000000000 --- a/homeassistant/components/cloud/util.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Utilities for the cloud integration.""" -from .const import DOMAIN - - -def get_mode(hass): - """Return the current mode of the cloud component. - - Async friendly. - """ - return hass.data[DOMAIN]['mode'] diff --git a/homeassistant/components/coinbase.py b/homeassistant/components/coinbase.py new file mode 100644 index 0000000000000..c40bd99b542ad --- /dev/null +++ b/homeassistant/components/coinbase.py @@ -0,0 +1,90 @@ +""" +Support for Coinbase. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/coinbase/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform +from homeassistant.util import Throttle + +REQUIREMENTS = ['coinbase==2.1.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'coinbase' + +CONF_API_SECRET = 'api_secret' +CONF_EXCHANGE_CURRENCIES = 'exchange_rate_currencies' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + +DATA_COINBASE = 'coinbase_cache' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_API_SECRET): cv.string, + vol.Optional(CONF_EXCHANGE_CURRENCIES, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Coinbase component. + + Will automatically setup sensors to support + wallets discovered on the network. + """ + api_key = config[DOMAIN].get(CONF_API_KEY) + api_secret = config[DOMAIN].get(CONF_API_SECRET) + exchange_currencies = config[DOMAIN].get(CONF_EXCHANGE_CURRENCIES) + + hass.data[DATA_COINBASE] = coinbase_data = CoinbaseData( + api_key, api_secret) + + if not hasattr(coinbase_data, 'accounts'): + return False + for account in coinbase_data.accounts.data: + load_platform(hass, 'sensor', DOMAIN, {'account': account}, config) + for currency in exchange_currencies: + if currency not in coinbase_data.exchange_rates.rates: + _LOGGER.warning("Currency %s not found", currency) + continue + native = coinbase_data.exchange_rates.currency + load_platform(hass, + 'sensor', + DOMAIN, + {'native_currency': native, + 'exchange_currency': currency}, + config) + + return True + + +class CoinbaseData(object): + """Get the latest data and update the states.""" + + def __init__(self, api_key, api_secret): + """Init the coinbase data object.""" + from coinbase.wallet.client import Client + self.client = Client(api_key, api_secret) + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from coinbase.""" + from coinbase.wallet.error import AuthenticationError + try: + self.accounts = self.client.get_accounts() + self.exchange_rates = self.client.get_exchange_rates() + except AuthenticationError as coinbase_error: + _LOGGER.error("Authentication error connecting" + " to coinbase: %s", coinbase_error) diff --git a/homeassistant/components/comfoconnect.py b/homeassistant/components/comfoconnect.py index ba2180078e3b0..425ed6f9c9a5f 100644 --- a/homeassistant/components/comfoconnect.py +++ b/homeassistant/components/comfoconnect.py @@ -8,11 +8,11 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_TOKEN, CONF_PIN, EVENT_HOMEASSISTANT_STOP) -from homeassistant.helpers import (discovery) -from homeassistant.helpers.dispatcher import (dispatcher_send) + CONF_HOST, CONF_NAME, CONF_PIN, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send REQUIREMENTS = ['pycomfoconnect==0.3'] @@ -115,7 +115,7 @@ def disconnect(self): self.comfoconnect.disconnect() def sensor_callback(self, var, value): - """Callback function for sensor updates.""" + """Call function for sensor updates.""" _LOGGER.debug("Got value from bridge: %d = %d", var, value) from pycomfoconnect import ( diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 9ce7f30529beb..5a8800d9583f2 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -8,46 +8,36 @@ from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID from homeassistant.setup import ( async_prepare_setup_platform, ATTR_COMPONENT) -from homeassistant.components.frontend import register_built_in_panel from homeassistant.components.http import HomeAssistantView from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] -SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script') -ON_DEMAND = ('zwave') +SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script', + 'entity_registry', 'config_entries') +ON_DEMAND = ('zwave',) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the config component.""" - register_built_in_panel(hass, 'config', 'Configuration', 'mdi:settings') + await hass.components.frontend.async_register_built_in_panel( + 'config', 'config', 'mdi:settings') - @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.""" @@ -57,6 +47,15 @@ def component_loaded(event): 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 @@ -85,11 +84,10 @@ def _write_value(self, hass, data, config_key, new_value): """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: @@ -97,11 +95,10 @@ def get(self, request, config_key): 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) @@ -120,10 +117,10 @@ def post(self, request, config_key): 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)) @@ -132,10 +129,9 @@ def post(self, request, config_key): '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() @@ -151,7 +147,7 @@ def _empty_config(self): def _get_value(self, hass, data, config_key): """Get value.""" - return data.get(config_key, {}) + return data.get(config_key) def _write_value(self, hass, data, config_key, new_value): """Set value.""" diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 64eccfaa2b89f..223159eb4158a 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,6 +1,9 @@ -"""Provide configuration end points for Z-Wave.""" +"""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 @@ @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 new file mode 100644 index 0000000000000..d2aa918eda269 --- /dev/null +++ b/homeassistant/components/config/config_entries.py @@ -0,0 +1,118 @@ +"""Http views to control the config manager.""" +import asyncio + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.http import HomeAssistantView +from homeassistant.helpers.data_entry_flow import ( + FlowManagerIndexView, FlowManagerResourceView) + + +REQUIREMENTS = ['voluptuous-serialize==1'] + + +@asyncio.coroutine +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.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'] != 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 ConfigManagerEntryIndexView(HomeAssistantView): + """View to get available config entries.""" + + url = '/api/config/config_entries/entry' + name = 'api:config:config_entries:entry' + + @asyncio.coroutine + def get(self, request): + """List flows in progress.""" + hass = request.app['hass'] + return self.json([{ + 'entry_id': entry.entry_id, + 'domain': entry.domain, + 'title': entry.title, + 'source': entry.source, + 'state': entry.state, + } for entry in hass.config_entries.async_entries()]) + + +class ConfigManagerEntryResourceView(HomeAssistantView): + """View to interact with a config entry.""" + + url = '/api/config/config_entries/entry/{entry_id}' + name = 'api:config:config_entries:entry:resource' + + @asyncio.coroutine + def delete(self, request, entry_id): + """Delete a config entry.""" + hass = request.app['hass'] + + try: + result = yield from hass.config_entries.async_remove(entry_id) + except config_entries.UnknownEntry: + return self.json_message('Invalid entry specified', 404) + + return self.json(result) + + +class ConfigManagerFlowIndexView(FlowManagerIndexView): + """View to create config flows.""" + + url = '/api/config/config_entries/flow' + name = 'api:config:config_entries:flow' + + @asyncio.coroutine + def get(self, request): + """List flows that are in progress but not started by a user. + + Example of a non-user initiated flow is a discovered Hue hub that + requires user interaction to finish setup. + """ + hass = request.app['hass'] + + return self.json([ + flw for flw in hass.config_entries.flow.async_progress() + if flw['source'] != data_entry_flow.SOURCE_USER]) + + +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' + + +class ConfigManagerAvailableFlowView(HomeAssistantView): + """View to query available flows.""" + + url = '/api/config/config_entries/flow_handlers' + name = 'api:config:config_entries:flow_handlers' + + @asyncio.coroutine + def get(self, request): + """List available flow handlers.""" + return self.json(config_entries.FLOWS) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py new file mode 100644 index 0000000000000..4b9a2c89da0ba --- /dev/null +++ b/homeassistant/components/config/entity_registry.py @@ -0,0 +1,55 @@ +"""HTTP views to interact with the entity registry.""" +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.helpers.entity_registry import async_get_registry + + +async def async_setup(hass): + """Enable the Entity Registry views.""" + hass.http.register_view(ConfigManagerEntityView) + return True + + +class ConfigManagerEntityView(HomeAssistantView): + """View to interact with an entity registry entry.""" + + url = '/api/config/entity_registry/{entity_id}' + name = 'api:config:entity_registry:entity' + + async def get(self, request, entity_id): + """Get the entity registry settings for an entity.""" + hass = request.app['hass'] + registry = await async_get_registry(hass) + entry = registry.entities.get(entity_id) + + if entry is None: + return self.json_message('Entry not found', 404) + + return self.json(_entry_dict(entry)) + + @RequestDataValidator(vol.Schema({ + # If passed in, we update value. Passing None will remove old value. + vol.Optional('name'): vol.Any(str, None), + })) + async def post(self, request, entity_id, data): + """Update the entity registry settings for an entity.""" + hass = request.app['hass'] + registry = await async_get_registry(hass) + + if entity_id not in registry.entities: + return self.json_message('Entry not found', 404) + + entry = registry.async_update_entity(entity_id, **data) + return self.json(_entry_dict(entry)) + + +@callback +def _entry_dict(entry): + """Helper to convert entry to API format.""" + return { + 'entity_id': entry.entity_id, + 'name': entry.name + } diff --git a/homeassistant/components/config/group.py b/homeassistant/components/config/group.py index 16e1900c64587..8b327faa95f38 100644 --- a/homeassistant/components/config/group.py +++ b/homeassistant/components/config/group.py @@ -1,8 +1,8 @@ """Provide configuration end points for Groups.""" import asyncio - +from homeassistant.const import SERVICE_RELOAD from homeassistant.components.config import EditKeyBasedConfigView -from homeassistant.components.group import GROUP_SCHEMA +from homeassistant.components.group import DOMAIN, GROUP_SCHEMA import homeassistant.helpers.config_validation as cv @@ -12,7 +12,13 @@ @asyncio.coroutine def async_setup(hass): """Set up the Group config API.""" + @asyncio.coroutine + def hook(hass): + """post_write_hook for Config View that reloads groups.""" + yield from hass.services.async_call(DOMAIN, SERVICE_RELOAD) + hass.http.register_view(EditKeyBasedConfigView( - 'group', 'config', CONFIG_PATH, cv.slug, GROUP_SCHEMA + 'group', 'config', CONFIG_PATH, cv.slug, GROUP_SCHEMA, + post_write_hook=hook )) return True diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index a40e1f640436f..c839ab7bc6ec9 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -1,17 +1,19 @@ """Provide configuration end points for Z-Wave.""" import asyncio +import logging +from collections import deque +from aiohttp.web import Response import homeassistant.core as ha -from homeassistant.const import HTTP_NOT_FOUND +from homeassistant.const import HTTP_NOT_FOUND, HTTP_OK from homeassistant.components.http import HomeAssistantView from homeassistant.components.config import EditKeyBasedConfigView from homeassistant.components.zwave import const, DEVICE_CONFIG_SCHEMA_ENTRY import homeassistant.helpers.config_validation as cv - +_LOGGER = logging.getLogger(__name__) CONFIG_PATH = 'zwave_device_config.yaml' OZW_LOG_FILENAME = 'OZW_Log.txt' -URL_API_OZW_LOG = '/api/zwave/ozwlog' @asyncio.coroutine @@ -25,12 +27,64 @@ def async_setup(hass): hass.http.register_view(ZWaveNodeGroupView) hass.http.register_view(ZWaveNodeConfigView) hass.http.register_view(ZWaveUserCodeView) - hass.http.register_static_path( - URL_API_OZW_LOG, hass.config.path(OZW_LOG_FILENAME), False) + hass.http.register_view(ZWaveLogView) + hass.http.register_view(ZWaveConfigWriteView) return True +class ZWaveLogView(HomeAssistantView): + """View to read the ZWave log file.""" + + url = "/api/zwave/ozwlog" + name = "api:zwave:ozwlog" + +# pylint: disable=no-self-use + @asyncio.coroutine + def get(self, request): + """Retrieve the lines from ZWave log.""" + try: + lines = int(request.query.get('lines', 0)) + except ValueError: + return Response(text='Invalid datetime', status=400) + + hass = request.app['hass'] + response = yield from hass.async_add_job(self._get_log, hass, lines) + + return Response(text='\n'.join(response)) + + def _get_log(self, hass, lines): + """Retrieve the logfile content.""" + logfilepath = hass.config.path(OZW_LOG_FILENAME) + with open(logfilepath, 'r') as logfile: + data = (line.rstrip() for line in logfile) + if lines == 0: + loglines = list(data) + else: + loglines = deque(data, lines) + return loglines + + +class ZWaveConfigWriteView(HomeAssistantView): + """View to save the ZWave configuration to zwcfg_xxxxx.xml.""" + + url = "/api/zwave/saveconfig" + name = "api:zwave:saveconfig" + + @ha.callback + def post(self, request): + """Save cache configuration to zwcfg_xxxxx.xml.""" + hass = request.app['hass'] + network = hass.data.get(const.DATA_NETWORK) + if network is None: + return self.json_message('No Z-Wave network data found', + HTTP_NOT_FOUND) + _LOGGER.info("Z-Wave configuration written to file.") + network.write_config() + return self.json_message('Z-Wave configuration saved to file.', + HTTP_OK) + + class ZWaveNodeValueView(HomeAssistantView): """View to return the node values.""" @@ -55,6 +109,7 @@ def get(self, request, node_id): 'label': entity_values.primary.label, 'index': entity_values.primary.index, 'instance': entity_values.primary.instance, + 'poll_intensity': entity_values.primary.poll_intensity, } return self.json(values_data) diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index 2da8967bddf5a..2c159633a9b54 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -15,7 +15,7 @@ ATTR_ENTITY_PICTURE from homeassistant.loader import bind_hass from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.util.async import run_callback_threadsafe +from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) _KEY_INSTANCE = 'configurator' @@ -50,15 +50,19 @@ def async_request_config( Will return an ID to be used for sequent calls. """ + if link_name is not None and link_url is not None: + description += '\n\n[{}]({})'.format(link_name, link_url) + + if description_image is not None: + description += '\n\n![Description image]({})'.format(description_image) + instance = hass.data.get(_KEY_INSTANCE) if instance is None: instance = hass.data[_KEY_INSTANCE] = Configurator(hass) request_id = instance.async_request_config( - name, callback, - description, description_image, submit_caption, - fields, link_name, link_url, entity_picture) + name, callback, description, submit_caption, fields, entity_picture) if DATA_REQUESTS not in hass.data: hass.data[DATA_REQUESTS] = {} @@ -137,9 +141,8 @@ def __init__(self, hass): @async_callback def async_request_config( - self, name, callback, - description, description_image, submit_caption, - fields, link_name, link_url, entity_picture): + self, name, callback, description, submit_caption, fields, + entity_picture): """Set up a request for configuration.""" entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, name, hass=self.hass) @@ -161,10 +164,7 @@ def async_request_config( data.update({ key: value for key, value in [ (ATTR_DESCRIPTION, description), - (ATTR_DESCRIPTION_IMAGE, description_image), (ATTR_SUBMIT_CAPTION, submit_caption), - (ATTR_LINK_NAME, link_name), - (ATTR_LINK_URL, link_url), ] if value is not None }) @@ -207,7 +207,7 @@ def deferred_remove(event): self.hass.bus.async_listen_once(EVENT_TIME_CHANGED, deferred_remove) - @async_callback + @asyncio.coroutine def async_handle_service_call(self, call): """Handle a configure service call.""" request_id = call.data.get(ATTR_CONFIGURE_ID) @@ -220,7 +220,8 @@ def async_handle_service_call(self, call): # field validation goes here? if callback: - self.hass.async_add_job(callback, call.data.get(ATTR_FIELDS, {})) + yield from self.hass.async_add_job(callback, + call.data.get(ATTR_FIELDS, {})) def _generate_unique_id(self): """Generate a unique configurator ID.""" diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py deleted file mode 100644 index 62611b82496fa..0000000000000 --- a/homeassistant/components/conversation.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -Support for functionality to have conversations with Home Assistant. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/conversation/ -""" -import asyncio -import logging -import re -import warnings - -import voluptuous as vol - -from homeassistant import core -from homeassistant.loader import bind_hass -from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, HTTP_BAD_REQUEST) -from homeassistant.helpers import intent, config_validation as cv -from homeassistant.components import http - - -REQUIREMENTS = ['fuzzywuzzy==0.15.1'] -DEPENDENCIES = ['http'] - -ATTR_TEXT = 'text' -DOMAIN = 'conversation' - -REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') - -SERVICE_PROCESS = 'process' - -SERVICE_PROCESS_SCHEMA = vol.Schema({ - vol.Required(ATTR_TEXT): cv.string, -}) - -CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({ - vol.Optional('intents'): vol.Schema({ - cv.string: vol.All(cv.ensure_list, [cv.string]) - }) -})}, extra=vol.ALLOW_EXTRA) - -_LOGGER = logging.getLogger(__name__) - - -@core.callback -@bind_hass -def async_register(hass, intent_type, utterances): - """Register an intent. - - Registrations don't require conversations to be loaded. They will become - active once the conversation component is loaded. - """ - intents = hass.data.get(DOMAIN) - - if intents is None: - intents = hass.data[DOMAIN] = {} - - conf = intents.get(intent_type) - - if conf is None: - conf = intents[intent_type] = [] - - conf.extend(_create_matcher(utterance) for utterance in utterances) - - -@asyncio.coroutine -def async_setup(hass, config): - """Register the process service.""" - warnings.filterwarnings('ignore', module='fuzzywuzzy') - - config = config.get(DOMAIN, {}) - intents = hass.data.get(DOMAIN) - - if intents is None: - intents = hass.data[DOMAIN] = {} - - for intent_type, utterances in config.get('intents', {}).items(): - conf = intents.get(intent_type) - - if conf is None: - conf = intents[intent_type] = [] - - conf.extend(_create_matcher(utterance) for utterance in utterances) - - @asyncio.coroutine - def process(service): - """Parse text into commands.""" - text = service.data[ATTR_TEXT] - yield from _process(hass, text) - - hass.services.async_register( - DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA) - - hass.http.register_view(ConversationProcessView) - - return True - - -def _create_matcher(utterance): - """Create a regex that matches the utterance.""" - parts = re.split(r'({\w+})', utterance) - group_matcher = re.compile(r'{(\w+)}') - - pattern = ['^'] - - for part in parts: - match = group_matcher.match(part) - - if match is None: - pattern.append(part) - continue - - pattern.append('(?P<{}>{})'.format(match.groups()[0], r'[\w ]+')) - - pattern.append('$') - return re.compile(''.join(pattern), re.I) - - -@asyncio.coroutine -def _process(hass, text): - """Process a line of text.""" - intents = hass.data.get(DOMAIN, {}) - - for intent_type, matchers in intents.items(): - for matcher in matchers: - match = matcher.match(text) - - if not match: - continue - - response = yield from intent.async_handle( - hass, DOMAIN, intent_type, - {key: {'value': value} for key, value - in match.groupdict().items()}, text) - return response - - from fuzzywuzzy import process as fuzzyExtract - text = text.lower() - match = REGEX_TURN_COMMAND.match(text) - - if not match: - _LOGGER.error("Unable to process: %s", text) - return None - - name, command = match.groups() - entities = {state.entity_id: state.name for state - in hass.states.async_all()} - entity_ids = fuzzyExtract.extractOne( - name, entities, score_cutoff=65)[2] - - if not entity_ids: - _LOGGER.error( - "Could not find entity id %s from text %s", name, text) - return None - - if command == 'on': - yield from hass.services.async_call( - core.DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity_ids, - }, blocking=True) - - elif command == 'off': - yield from hass.services.async_call( - core.DOMAIN, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: entity_ids, - }, blocking=True) - - else: - _LOGGER.error('Got unsupported command %s from text %s', - command, text) - - return None - - -class ConversationProcessView(http.HomeAssistantView): - """View to retrieve shopping list content.""" - - url = '/api/conversation/process' - name = "api:conversation:process" - - @asyncio.coroutine - def post(self, request): - """Send a request for processing.""" - hass = request.app['hass'] - try: - data = yield from request.json() - except ValueError: - return self.json_message('Invalid JSON specified', - HTTP_BAD_REQUEST) - - text = data.get('text') - - if text is None: - return self.json_message('Missing "text" key in JSON.', - HTTP_BAD_REQUEST) - - intent_result = yield from _process(hass, text) - - if intent_result is None: - intent_result = intent.IntentResponse() - intent_result.async_set_speech("Sorry, I didn't understand that") - - return self.json(intent_result) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py new file mode 100644 index 0000000000000..9cb00a84583ae --- /dev/null +++ b/homeassistant/components/conversation/__init__.py @@ -0,0 +1,223 @@ +""" +Support for functionality to have conversations with Home Assistant. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/conversation/ +""" +import logging +import re + +import voluptuous as vol + +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__) + +ATTR_TEXT = 'text' + +DEPENDENCIES = ['http'] +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({ + vol.Required(ATTR_TEXT): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({ + vol.Optional('intents'): vol.Schema({ + cv.string: vol.All(cv.ensure_list, [cv.string]) + }) +})}, extra=vol.ALLOW_EXTRA) + + +@core.callback +@bind_hass +def async_register(hass, intent_type, utterances): + """Register utterances and any custom intents. + + Registrations don't require conversations to be loaded. They will become + active once the conversation component is loaded. + """ + intents = hass.data.get(DOMAIN) + + if intents is None: + intents = hass.data[DOMAIN] = {} + + conf = intents.get(intent_type) + + if conf is None: + conf = intents[intent_type] = [] + + for utterance in utterances: + if isinstance(utterance, REGEX_TYPE): + conf.append(utterance) + else: + conf.append(_create_matcher(utterance)) + + +async def async_setup(hass, config): + """Register the process service.""" + config = config.get(DOMAIN, {}) + intents = hass.data.get(DOMAIN) + + if intents is None: + intents = hass.data[DOMAIN] = {} + + for intent_type, utterances in config.get('intents', {}).items(): + conf = intents.get(intent_type) + + if conf is None: + conf = intents[intent_type] = [] + + conf.extend(_create_matcher(utterance) for utterance in utterances) + + 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: + _LOGGER.error('Error processing %s: %s', text, err) + + hass.services.async_register( + DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA) + + hass.http.register_view(ConversationProcessView) + + # We strip trailing 's' from name because our state matcher will fail + # if a letter is not there. By removing 's' we can match singular and + # plural names. + + async_register(hass, intent.INTENT_TURN_ON, [ + 'Turn [the] [a] {name}[s] on', + 'Turn on [the] [a] [an] {name}[s]', + ]) + async_register(hass, intent.INTENT_TURN_OFF, [ + 'Turn [the] [a] [an] {name}[s] off', + 'Turn off [the] [a] [an] {name}[s]', + ]) + async_register(hass, intent.INTENT_TOGGLE, [ + 'Toggle [the] [a] [an] {name}[s]', + '[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 + + +def _create_matcher(utterance): + """Create a regex that matches the utterance.""" + # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL + # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} + parts = re.split(r'({\w+}|\[[\w\s]+\] *)', utterance) + # Pattern to extract name from GROUP part. Matches {name} + group_matcher = re.compile(r'{(\w+)}') + # Pattern to extract text from OPTIONAL part. Matches [the color] + optional_matcher = re.compile(r'\[([\w ]+)\] *') + + pattern = ['^'] + for part in parts: + group_match = group_matcher.match(part) + optional_match = optional_matcher.match(part) + + # Normal part + if group_match is None and optional_match is None: + pattern.append(part) + continue + + # Group part + if group_match is not None: + pattern.append( + r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0])) + + # Optional part + elif optional_match is not None: + pattern.append(r'(?:{} *)?'.format(optional_match.groups()[0])) + + pattern.append('$') + return re.compile(''.join(pattern), re.I) + + +async def _process(hass, text): + """Process a line of text.""" + intents = hass.data.get(DOMAIN, {}) + + for intent_type, matchers in intents.items(): + for matcher in matchers: + match = matcher.match(text) + + if not match: + continue + + response = await hass.helpers.intent.async_handle( + DOMAIN, intent_type, + {key: {'value': value} for key, value + in match.groupdict().items()}, text) + return response + + +class ConversationProcessView(http.HomeAssistantView): + """View to retrieve shopping list content.""" + + url = '/api/conversation/process' + name = "api:conversation:process" + + @RequestDataValidator(vol.Schema({ + vol.Required('text'): str, + })) + async def post(self, request, data): + """Send a request for processing.""" + hass = request.app['hass'] + + try: + intent_result = await _process(hass, data['text']) + except intent.IntentHandleError as err: + intent_result = intent.IntentResponse() + intent_result.async_set_speech(str(err)) + + if intent_result is None: + intent_result = intent.IntentResponse() + intent_result.async_set_speech("Sorry, I didn't understand that") + + return self.json(intent_result) diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml new file mode 100644 index 0000000000000..a1b980d8e05a3 --- /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.py b/homeassistant/components/counter/__init__.py similarity index 90% rename from homeassistant/components/counter.py rename to homeassistant/components/counter/__init__.py index 64421306644f7..2df17a4e50a9c 100644 --- a/homeassistant/components/counter.py +++ b/homeassistant/components/counter/__init__.py @@ -6,12 +6,10 @@ """ import asyncio import logging -import os import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) from homeassistant.core import callback from homeassistant.helpers.entity import Entity @@ -133,20 +131,12 @@ def async_handler_service(service): if tasks: yield from asyncio.wait(tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) - hass.services.async_register( - DOMAIN, SERVICE_INCREMENT, async_handler_service, - descriptions[DOMAIN][SERVICE_INCREMENT], SERVICE_SCHEMA) + DOMAIN, SERVICE_INCREMENT, async_handler_service) hass.services.async_register( - DOMAIN, SERVICE_DECREMENT, async_handler_service, - descriptions[DOMAIN][SERVICE_DECREMENT], SERVICE_SCHEMA) + DOMAIN, SERVICE_DECREMENT, async_handler_service) hass.services.async_register( - DOMAIN, SERVICE_RESET, async_handler_service, - descriptions[DOMAIN][SERVICE_RESET], SERVICE_SCHEMA) + DOMAIN, SERVICE_RESET, async_handler_service) yield from component.async_add_entities(entities) return True diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml new file mode 100644 index 0000000000000..ef76f9b9eacbf --- /dev/null +++ b/homeassistant/components/counter/services.yaml @@ -0,0 +1,20 @@ +# Describes the format for available counter services + +decrement: + description: Decrement a counter. + fields: + entity_id: + description: Entity id of the counter to decrement. + example: 'counter.count0' +increment: + description: Increment a counter. + fields: + entity_id: + description: Entity id of the counter to increment. + example: 'counter.count0' +reset: + description: Reset a counter. + fields: + entity_id: + description: Entity id of the counter to reset. + example: 'counter.count0' \ No newline at end of file diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 23c0be1a43e1b..e4c8f5634cf4a 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -8,17 +8,16 @@ from datetime import timedelta import functools as ft import logging -import os import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass from homeassistant.helpers.entity_component import EntityComponent 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, @@ -57,6 +56,9 @@ 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, }) @@ -152,16 +154,14 @@ def stop_cover_tilt(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_STOP_COVER_TILT, data) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Track states and offer events for covers.""" component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_COVERS) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_handle_cover_service(service): + async def async_handle_cover_service(service): """Handle calls to the cover services.""" covers = component.async_extract_from_service(service) method = SERVICE_TO_METHOD.get(service.service) @@ -169,35 +169,28 @@ def async_handle_cover_service(service): params.pop(ATTR_ENTITY_ID, None) # call method - for cover in covers: - yield from getattr(cover, method['method'])(**params) - update_tasks = [] - for cover in covers: + await getattr(cover, method['method'])(**params) if not cover.should_poll: continue - - update_coro = hass.async_add_job( - cover.async_update_ha_state(True)) - if hasattr(cover, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(cover.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) - - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) + await asyncio.wait(update_tasks, loop=hass.loop) for service_name in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service_name].get( 'schema', COVER_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, service_name, async_handle_cover_service, - descriptions.get(service_name), schema=schema) + 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/abode.py b/homeassistant/components/cover/abode.py index b09c9e5e00762..6eb0369aa3f20 100644 --- a/homeassistant/components/cover/abode.py +++ b/homeassistant/components/cover/abode.py @@ -6,7 +6,7 @@ """ import logging -from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN from homeassistant.components.cover import CoverDevice @@ -19,31 +19,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Abode cover devices.""" import abodepy.helpers.constants as CONST - abode = hass.data[DATA_ABODE] + data = hass.data[ABODE_DOMAIN] - sensors = [] - for sensor in abode.get_devices(type_filter=(CONST.DEVICE_SECURE_BARRIER)): - sensors.append(AbodeCover(abode, sensor)) + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER): + if data.is_excluded(device): + continue - add_devices(sensors) + devices.append(AbodeCover(data, device)) + + data.devices.extend(devices) + + add_devices(devices) class AbodeCover(AbodeDevice, CoverDevice): """Representation of an Abode cover.""" - def __init__(self, controller, device): - """Initialize the Abode device.""" - AbodeDevice.__init__(self, controller, device) - @property def is_closed(self): """Return true if cover is closed, else False.""" - return self._device.is_open is False + return not self._device.is_open - def close_cover(self): + def close_cover(self, **kwargs): """Issue close command to cover.""" self._device.close_cover() - def open_cover(self): + def open_cover(self, **kwargs): """Issue open command to cover.""" self._device.open_cover() diff --git a/homeassistant/components/cover/demo.py b/homeassistant/components/cover/demo.py index 827b50c8af9d1..70e681f11207f 100644 --- a/homeassistant/components/cover/demo.py +++ b/homeassistant/components/cover/demo.py @@ -5,7 +5,8 @@ https://home-assistant.io/components/demo/ """ from homeassistant.components.cover import ( - CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE) + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION, + ATTR_TILT_POSITION) from homeassistant.helpers.event import track_utc_time_change @@ -137,8 +138,9 @@ def open_cover_tilt(self, **kwargs): self._listen_cover_tilt() self._requested_closing_tilt = False - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" + position = kwargs.get(ATTR_POSITION) self._set_position = round(position, -1) if self._position == position: return @@ -146,8 +148,9 @@ def set_cover_position(self, position, **kwargs): self._listen_cover() self._requested_closing = position < self._position - def set_cover_tilt_position(self, tilt_position, **kwargs): + def set_cover_tilt_position(self, **kwargs): """Move the cover til to a specific position.""" + tilt_position = kwargs.get(ATTR_TILT_POSITION) self._set_tilt_position = round(tilt_position, -1) if self._tilt_position == tilt_position: return diff --git a/homeassistant/components/cover/garadget.py b/homeassistant/components/cover/garadget.py index 22f5fd889a2dc..c19aa69c8f04b 100644 --- a/homeassistant/components/cover/garadget.py +++ b/homeassistant/components/cover/garadget.py @@ -201,21 +201,21 @@ def _check_state(self, now): """Check the state of the service during an operation.""" self.schedule_update_ha_state(True) - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" if self._state not in ['close', 'closing']: ret = self._put_command('setState', 'close') self._start_watcher('close') return ret.get('return_value') == 1 - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" if self._state not in ['open', 'opening']: ret = self._put_command('setState', 'open') self._start_watcher('open') return ret.get('return_value') == 1 - def stop_cover(self): + def stop_cover(self, **kwargs): """Stop the door where it is.""" if self._state not in ['stopped']: ret = self._put_command('setState', 'stop') diff --git a/homeassistant/components/cover/gogogate2.py b/homeassistant/components/cover/gogogate2.py new file mode 100644 index 0000000000000..2b91591e71b9d --- /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 0000000000000..c1ea33a9cc72e --- /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/homematic.py b/homeassistant/components/cover/homematic.py index 9e3d675cabebe..2736b656a1522 100644 --- a/homeassistant/components/cover/homematic.py +++ b/homeassistant/components/cover/homematic.py @@ -5,9 +5,11 @@ https://home-assistant.io/components/cover.homematic/ """ import logging -from homeassistant.const import STATE_UNKNOWN -from homeassistant.components.cover import CoverDevice, ATTR_POSITION + +from homeassistant.components.cover import CoverDevice, ATTR_POSITION,\ + ATTR_TILT_POSITION from homeassistant.components.homematic import HMDevice, ATTR_DISCOVER_DEVICES +from homeassistant.const import STATE_UNKNOWN _LOGGER = logging.getLogger(__name__) @@ -66,6 +68,43 @@ def stop_cover(self, **kwargs): self._hmdevice.stop(self._channel) def _init_data_struct(self): - """Generate a data dictoinary (self._data) from metadata.""" + """Generate a data dictionary (self._data) from metadata.""" self._state = "LEVEL" self._data.update({self._state: STATE_UNKNOWN}) + if "LEVEL_2" in self._hmdevice.WRITENODE: + self._data.update( + {'LEVEL_2': STATE_UNKNOWN}) + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + if 'LEVEL_2' not in self._data: + return None + + return int(self._data.get('LEVEL_2', 0) * 100) + + def set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + if "LEVEL_2" in self._data and ATTR_TILT_POSITION in kwargs: + position = float(kwargs[ATTR_TILT_POSITION]) + position = min(100, max(0, position)) + level = position / 100.0 + self._hmdevice.set_cover_tilt_position(level, self._channel) + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + if "LEVEL_2" in self._data: + self._hmdevice.open_slats() + + def close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + if "LEVEL_2" in self._data: + self._hmdevice.close_slats() + + def stop_cover_tilt(self, **kwargs): + """Stop cover tilt.""" + if "LEVEL_2" in self._data: + self.stop_cover(**kwargs) diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py index 1e83038278cea..82ca60e84e6c9 100644 --- a/homeassistant/components/cover/isy994.py +++ b/homeassistant/components/cover/isy994.py @@ -8,8 +8,10 @@ from typing import Callable # noqa from homeassistant.components.cover import CoverDevice, DOMAIN -import homeassistant.components.isy994 as isy -from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN +from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, + ISYDevice) +from homeassistant.const import ( + STATE_OPEN, STATE_CLOSED, STATE_OPENING, STATE_CLOSING, STATE_UNKNOWN) from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -17,45 +19,29 @@ VALUE_TO_STATE = { 0: STATE_CLOSED, 101: STATE_UNKNOWN, + 102: 'stopped', + 103: STATE_CLOSING, + 104: STATE_OPENING } -UOM = ['97'] -STATES = [STATE_OPEN, STATE_CLOSED, 'closing', 'opening', 'stopped'] - # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 cover platform.""" - if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error("A connection has not been made to the ISY controller") - return False - devices = [] - - for node in isy.filter_nodes(isy.NODES, units=UOM, states=STATES): + for node in hass.data[ISY994_NODES][DOMAIN]: devices.append(ISYCoverDevice(node)) - for program in isy.PROGRAMS.get(DOMAIN, []): - try: - status = program[isy.KEY_STATUS] - actions = program[isy.KEY_ACTIONS] - assert actions.dtype == 'program', 'Not a program' - except (KeyError, AssertionError): - pass - else: - devices.append(ISYCoverProgram(program.name, status, actions)) + for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]: + devices.append(ISYCoverProgram(name, status, actions)) add_devices(devices) -class ISYCoverDevice(isy.ISYDevice, CoverDevice): +class ISYCoverDevice(ISYDevice, CoverDevice): """Representation of an ISY994 cover device.""" - def __init__(self, node: object): - """Initialize the ISY994 cover device.""" - isy.ISYDevice.__init__(self, node) - @property def current_cover_position(self) -> int: """Return the current cover position.""" @@ -69,6 +55,8 @@ def is_closed(self) -> bool: @property def state(self) -> str: """Get the state of the ISY994 cover device.""" + if self.is_unknown(): + return None return VALUE_TO_STATE.get(self.value, STATE_OPEN) def open_cover(self, **kwargs) -> None: @@ -87,7 +75,7 @@ class ISYCoverProgram(ISYCoverDevice): def __init__(self, name: str, node: object, actions: object) -> None: """Initialize the ISY994 cover program.""" - ISYCoverDevice.__init__(self, node) + super().__init__(node) self._name = name self._actions = actions diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index e4c2931983d51..83668924268e0 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -4,18 +4,18 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.knx/ """ -import asyncio + import voluptuous as vol -from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES -from homeassistant.helpers.event import async_track_utc_time_change from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE, - SUPPORT_SET_POSITION, SUPPORT_STOP, SUPPORT_SET_TILT_POSITION, - ATTR_POSITION, ATTR_TILT_POSITION) -from homeassistant.core import callback + ATTR_POSITION, ATTR_TILT_POSITION, PLATFORM_SCHEMA, SUPPORT_CLOSE, + SUPPORT_OPEN, SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP, CoverDevice) +from homeassistant.components.knx import ATTR_DISCOVER_DEVICES, DATA_KNX from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_utc_time_change CONF_MOVE_LONG_ADDRESS = 'move_long_address' CONF_MOVE_SHORT_ADDRESS = 'move_short_address' @@ -49,35 +49,28 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, add_devices, - discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up cover(s) for KNX platform.""" - if DATA_KNX not in hass.data \ - or not hass.data[DATA_KNX].initialized: - return False - if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) - - return True + async_add_devices_config(hass, config, async_add_devices) @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up covers for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXCover(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): - """Set up cover for KNX platform configured within plattform.""" +def async_add_devices_config(hass, config, async_add_devices): + """Set up cover for KNX platform configured within platform.""" import xknx cover = xknx.devices.Cover( hass.data[DATA_KNX].xknx, @@ -90,23 +83,20 @@ def async_add_devices_config(hass, config, add_devices): group_address_angle_state=config.get(CONF_ANGLE_STATE_ADDRESS), group_address_position=config.get(CONF_POSITION_ADDRESS), travel_time_down=config.get(CONF_TRAVELLING_TIME_DOWN), - travel_time_up=config.get(CONF_TRAVELLING_TIME_UP)) + travel_time_up=config.get(CONF_TRAVELLING_TIME_UP), + invert_position=config.get(CONF_INVERT_POSITION), + invert_angle=config.get(CONF_INVERT_ANGLE)) - invert_position = config.get(CONF_INVERT_POSITION) - invert_angle = config.get(CONF_INVERT_ANGLE) hass.data[DATA_KNX].xknx.devices.add(cover) - add_devices([KNXCover(hass, cover, invert_position, invert_angle)]) + async_add_devices([KNXCover(hass, cover)]) class KNXCover(CoverDevice): """Representation of a KNX cover.""" - def __init__(self, hass, device, invert_position=False, - invert_angle=False): + def __init__(self, hass, device): """Initialize the cover.""" self.device = device - self.invert_position = invert_position - self.invert_angle = invert_angle self.hass = hass self.async_register_callbacks() @@ -115,11 +105,10 @@ def __init__(self, hass, device, invert_position=False, @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): - """Callback after device was updated.""" + async def after_update_callback(device): + """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property @@ -127,6 +116,11 @@ def name(self): """Return the name of the KNX device.""" return self.device.name + @property + def available(self): + """Return True if entity is available.""" + return self.hass.data[DATA_KNX].connected + @property def should_poll(self): """No polling needed within KNX.""" @@ -144,42 +138,35 @@ def supported_features(self): @property def current_cover_position(self): """Return the current position of the cover.""" - return int(self.from_knx_position( - self.device.current_position(), - self.invert_position)) + return self.device.current_position() @property def is_closed(self): """Return if the cover is closed.""" return self.device.is_closed() - @asyncio.coroutine - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Close the cover.""" if not self.device.is_closed(): - yield from self.device.set_down() + await self.device.set_down() self.start_auto_updater() - @asyncio.coroutine - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Open the cover.""" if not self.device.is_open(): - yield from self.device.set_up() + await self.device.set_up() self.start_auto_updater() - @asyncio.coroutine - def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] - knx_position = self.to_knx_position(position, self.invert_position) - yield from self.device.set_position(knx_position) + await self.device.set_position(position) self.start_auto_updater() - @asyncio.coroutine - def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the cover.""" - yield from self.device.stop() + await self.device.stop() self.stop_auto_updater() @property @@ -187,17 +174,13 @@ def current_cover_tilt_position(self): """Return current tilt position of cover.""" if not self.device.supports_angle: return None - return int(self.from_knx_position( - self.device.angle, - self.invert_angle)) + return self.device.current_angle() - @asyncio.coroutine - def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" if ATTR_TILT_POSITION in kwargs: - position = kwargs[ATTR_TILT_POSITION] - knx_position = self.to_knx_position(position, self.invert_angle) - yield from self.device.set_angle(knx_position) + tilt_position = kwargs[ATTR_TILT_POSITION] + await self.device.set_angle(tilt_position) def start_auto_updater(self): """Start the autoupdater to update HASS while cover is moving.""" @@ -213,27 +196,10 @@ def stop_auto_updater(self): @callback def auto_updater_hook(self, now): - """Callback for autoupdater.""" + """Call for the autoupdater.""" # pylint: disable=unused-argument - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self.device.position_reached(): self.stop_auto_updater() self.hass.add_job(self.device.auto_stop_if_necessary()) - - @staticmethod - def from_knx_position(raw, invert): - """Convert KNX position [0...255] to hass position [100...0].""" - position = round((raw/256)*100) - if not invert: - position = 100 - position - return position - - @staticmethod - def to_knx_position(value, invert): - """Convert hass position [100...0] to KNX position [0...255].""" - knx_position = round(value/100*255.4) - if not invert: - knx_position = 255-knx_position - print(value, " -> ", knx_position) - return knx_position diff --git a/homeassistant/components/cover/lutron.py b/homeassistant/components/cover/lutron.py new file mode 100644 index 0000000000000..4e38681a310f3 --- /dev/null +++ b/homeassistant/components/cover/lutron.py @@ -0,0 +1,76 @@ +""" +Support for Lutron shades. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.lutron/ +""" +import logging + +from homeassistant.components.cover import ( + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, + ATTR_POSITION) +from homeassistant.components.lutron import ( + LutronDevice, LUTRON_DEVICES, LUTRON_CONTROLLER) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['lutron'] + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Lutron shades.""" + devs = [] + for (area_name, device) in hass.data[LUTRON_DEVICES]['cover']: + dev = LutronCover(area_name, device, hass.data[LUTRON_CONTROLLER]) + devs.append(dev) + + add_devices(devs, True) + return True + + +class LutronCover(LutronDevice, CoverDevice): + """Representation of a Lutron shade.""" + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self._lutron_device.last_level() < 1 + + @property + def current_cover_position(self): + """Return the current position of cover.""" + return self._lutron_device.last_level() + + def close_cover(self, **kwargs): + """Close the cover.""" + self._lutron_device.level = 0 + + def open_cover(self, **kwargs): + """Open the cover.""" + self._lutron_device.level = 100 + + def set_cover_position(self, **kwargs): + """Move the shade to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + self._lutron_device.level = position + + def update(self): + """Call when forcing a refresh of the device.""" + # Reading the property (rather than last_level()) fetches value + level = self._lutron_device.level + _LOGGER.debug("Lutron ID: %d updated to %f", + self._lutron_device.id, level) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attr = {} + attr['Lutron Integration ID'] = self._lutron_device.id + return attr diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 31e4f1e3cf281..6ad9b093ed84a 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.lutron_caseta/ """ +import asyncio import logging from homeassistant.components.cover import ( @@ -18,7 +19,8 @@ # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Lutron Caseta shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -27,7 +29,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = LutronCasetaCover(cover_device, bridge) devs.append(dev) - add_devices(devs, True) + async_add_devices(devs, True) class LutronCasetaCover(LutronCasetaDevice, CoverDevice): @@ -48,21 +50,25 @@ def current_cover_position(self): """Return the current position of cover.""" return self._state['current_state'] - def close_cover(self, **kwargs): + @asyncio.coroutine + def async_close_cover(self, **kwargs): """Close the cover.""" self._smartbridge.set_value(self._device_id, 0) - def open_cover(self, **kwargs): + @asyncio.coroutine + def async_open_cover(self, **kwargs): """Open the cover.""" self._smartbridge.set_value(self._device_id, 100) - def set_cover_position(self, **kwargs): + @asyncio.coroutine + def async_set_cover_position(self, **kwargs): """Move the shade to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] self._smartbridge.set_value(self._device_id, position) - def update(self): + @asyncio.coroutine + def async_update(self): """Call when forcing a refresh of the device.""" self._state = self._smartbridge.get_device_by_id(self._device_id) _LOGGER.debug(self._state) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index eab64fd7abbae..0f31d3a9fe030 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -21,8 +21,9 @@ CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, - valid_publish_topic, valid_subscribe_topic) + CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + valid_publish_topic, valid_subscribe_topic, MqttAvailability) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -64,9 +65,9 @@ SUPPORT_SET_TILT_POSITION) PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_COMMAND_TOPIC, default=None): valid_publish_topic, - vol.Optional(CONF_POSITION_TOPIC, default=None): valid_publish_topic, - vol.Optional(CONF_SET_POSITION_TEMPLATE, default=None): cv.template, + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_POSITION_TOPIC): valid_publish_topic, + vol.Optional(CONF_SET_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -77,8 +78,8 @@ vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string, vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_TILT_COMMAND_TOPIC, default=None): valid_publish_topic, - vol.Optional(CONF_TILT_STATUS_TOPIC, default=None): valid_subscribe_topic, + vol.Optional(CONF_TILT_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_TILT_STATUS_TOPIC): valid_subscribe_topic, vol.Optional(CONF_TILT_CLOSED_POSITION, default=DEFAULT_TILT_CLOSED_POSITION): int, vol.Optional(CONF_TILT_OPEN_POSITION, @@ -89,12 +90,15 @@ default=DEFAULT_TILT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT_INVERT_STATE, default=DEFAULT_TILT_INVERT_STATE): cv.boolean, -}) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the MQTT Cover.""" + 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 @@ -106,6 +110,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), + config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_TILT_COMMAND_TOPIC), config.get(CONF_TILT_STATUS_TOPIC), config.get(CONF_QOS), @@ -115,6 +120,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_PAYLOAD_OPEN), config.get(CONF_PAYLOAD_CLOSE), config.get(CONF_PAYLOAD_STOP), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), config.get(CONF_OPTIMISTIC), value_template, config.get(CONF_TILT_OPEN_POSITION), @@ -128,16 +135,19 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): )]) -class MqttCover(CoverDevice): +class MqttCover(MqttAvailability, CoverDevice): """Representation of a cover that can be controlled using MQTT.""" - def __init__(self, name, state_topic, command_topic, tilt_command_topic, - tilt_status_topic, qos, retain, state_open, state_closed, - payload_open, payload_close, payload_stop, + def __init__(self, name, state_topic, command_topic, availability_topic, + tilt_command_topic, tilt_status_topic, qos, retain, + state_open, state_closed, payload_open, payload_close, + payload_stop, payload_available, payload_not_available, optimistic, value_template, tilt_open_position, tilt_closed_position, tilt_min, tilt_max, tilt_optimistic, tilt_invert, position_topic, set_position_template): """Initialize the cover.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._position = None self._state = None self._name = name @@ -166,10 +176,9 @@ def __init__(self, name, state_topic, command_topic, tilt_command_topic, @asyncio.coroutine def async_added_to_hass(self): - """Subscribe MQTT events. + """Subscribe MQTT events.""" + yield from super().async_added_to_hass() - This method is a coroutine. - """ @callback def tilt_updated(topic, payload, qos): """Handle tilt updates.""" @@ -178,11 +187,11 @@ def tilt_updated(topic, payload, qos): level = self.find_percentage_in_range(float(payload)) self._tilt_value = level - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback - def message_received(topic, payload, qos): - """Handle new MQTT message.""" + def state_message_received(topic, payload, qos): + """Handle new MQTT state messages.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) @@ -203,14 +212,15 @@ def message_received(topic, payload, qos): payload) return - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._state_topic is None: # Force into optimistic mode. self._optimistic = True else: yield from mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + self.hass, self._state_topic, + state_message_received, self._qos) if self._tilt_status_topic is None: self._tilt_optimistic = True @@ -275,7 +285,7 @@ def async_open_cover(self, **kwargs): if self._optimistic: # Optimistically assume that cover has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover(self, **kwargs): @@ -289,7 +299,7 @@ def async_close_cover(self, **kwargs): if self._optimistic: # Optimistically assume that cover has changed state. self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_stop_cover(self, **kwargs): @@ -309,7 +319,7 @@ def async_open_cover_tilt(self, **kwargs): self._retain) if self._tilt_optimistic: self._tilt_value = self._tilt_open_position - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover_tilt(self, **kwargs): @@ -319,7 +329,7 @@ def async_close_cover_tilt(self, **kwargs): self._retain) if self._tilt_optimistic: self._tilt_value = self._tilt_closed_position - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): diff --git a/homeassistant/components/cover/myq.py b/homeassistant/components/cover/myq.py index 8d59a90278c69..f07d3849fae77 100644 --- a/homeassistant/components/cover/myq.py +++ b/homeassistant/components/cover/myq.py @@ -84,11 +84,11 @@ def is_closed(self): """Return true if cover is closed, else False.""" return self._status == STATE_CLOSED - def close_cover(self): + def close_cover(self, **kwargs): """Issue close command to cover.""" self.myq.close_device(self.device_id) - def open_cover(self): + def open_cover(self, **kwargs): """Issue open command to cover.""" self.myq.open_device(self.device_id) diff --git a/homeassistant/components/cover/mysensors.py b/homeassistant/components/cover/mysensors.py index cd4ff62b3e905..3f8eb05471037 100644 --- a/homeassistant/components/cover/mysensors.py +++ b/homeassistant/components/cover/mysensors.py @@ -5,14 +5,16 @@ https://home-assistant.io/components/cover.mysensors/ """ from homeassistant.components import mysensors -from homeassistant.components.cover import CoverDevice, ATTR_POSITION, DOMAIN -from homeassistant.const import STATE_ON, STATE_OFF +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): - """Setup 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 @@ def current_cover_position(self): 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 @@ def open_cover(self, **kwargs): 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 @@ def close_cover(self, **kwargs): 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 @@ def set_cover_position(self, **kwargs): 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 d98c71e25fb44..028a7a0c9fc8a 100644 --- a/homeassistant/components/cover/opengarage.py +++ b/homeassistant/components/cover/opengarage.py @@ -18,30 +18,31 @@ _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 @@ 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 @@ def __init__(self, hass, args): 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 @@ -115,18 +116,18 @@ def device_state_attributes(self): @property def is_closed(self): """Return if the cover is closed.""" - if self._state == STATE_UNKNOWN: + if self._state in [STATE_UNKNOWN, STATE_OFFLINE]: return None return self._state in [STATE_CLOSED, STATE_OPENING] - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" if self._state not in [STATE_CLOSED, STATE_CLOSING]: self._state_before_move = self._state self._state = STATE_CLOSING self._push_button() - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" if self._state not in [STATE_OPEN, STATE_OPENING]: self._state_before_move = self._state @@ -138,8 +139,8 @@ def update(self): 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 @@ def update(self): 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 @@ def _get_status(self): 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/rflink.py b/homeassistant/components/cover/rflink.py new file mode 100644 index 0000000000000..a9b7598159f96 --- /dev/null +++ b/homeassistant/components/cover/rflink.py @@ -0,0 +1,121 @@ +""" +Support for Rflink Cover devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.rflink/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components.rflink import ( + DATA_ENTITY_GROUP_LOOKUP, DATA_ENTITY_LOOKUP, + DEVICE_DEFAULTS_SCHEMA, EVENT_KEY_COMMAND, RflinkCommand) +from homeassistant.components.cover import ( + CoverDevice, PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME + + +DEPENDENCIES = ['rflink'] + +_LOGGER = logging.getLogger(__name__) + + +CONF_ALIASES = 'aliases' +CONF_GROUP_ALIASES = 'group_aliases' +CONF_GROUP = 'group' +CONF_NOGROUP_ALIASES = 'nogroup_aliases' +CONF_DEVICE_DEFAULTS = 'device_defaults' +CONF_DEVICES = 'devices' +CONF_AUTOMATIC_ADD = 'automatic_add' +CONF_FIRE_EVENT = 'fire_event' +CONF_IGNORE_DEVICES = 'ignore_devices' +CONF_RECONNECT_INTERVAL = 'reconnect_interval' +CONF_SIGNAL_REPETITIONS = 'signal_repetitions' +CONF_WAIT_FOR_ACK = 'wait_for_ack' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})): + DEVICE_DEFAULTS_SCHEMA, + vol.Optional(CONF_DEVICES, default={}): vol.Schema({ + cv.string: { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_GROUP_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NOGROUP_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), + vol.Optional(CONF_GROUP, default=True): cv.boolean, + }, + }), +}) + + +def devices_from_config(domain_config, hass=None): + """Parse configuration and add Rflink cover devices.""" + devices = [] + for device_id, config in domain_config[CONF_DEVICES].items(): + device_config = dict(domain_config[CONF_DEVICE_DEFAULTS], **config) + device = RflinkCover(device_id, hass, **device_config) + devices.append(device) + + # Register entity (and aliases) to listen to incoming rflink events + # Device id and normal aliases respond to normal and group command + hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][device_id].append(device) + if config[CONF_GROUP]: + hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][device_id].append(device) + for _id in config[CONF_ALIASES]: + hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(device) + hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(device) + return devices + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Rflink cover platform.""" + async_add_devices(devices_from_config(config, hass)) + + +class RflinkCover(RflinkCommand, CoverDevice): + """Rflink entity which can switch on/stop/off (eg: cover).""" + + def _handle_event(self, event): + """Adjust state if Rflink picks up a remote command for this device.""" + self.cancel_queued_send_commands() + + command = event['command'] + if command in ['on', 'allon']: + self._state = True + elif command in ['off', 'alloff']: + self._state = False + + @property + def should_poll(self): + """No polling available in RFlink cover.""" + return False + + @property + def is_closed(self): + """Return if the cover is closed.""" + return None + + def async_close_cover(self, **kwargs): + """Turn the device close.""" + return self._async_handle_command("close_cover") + + def async_open_cover(self, **kwargs): + """Turn the device open.""" + return self._async_handle_command("open_cover") + + def async_stop_cover(self, **kwargs): + """Turn the device stop.""" + return self._async_handle_command("stop_cover") diff --git a/homeassistant/components/cover/rfxtrx.py b/homeassistant/components/cover/rfxtrx.py index 0e28d3ef7017c..aefb7ab89d711 100644 --- a/homeassistant/components/cover/rfxtrx.py +++ b/homeassistant/components/cover/rfxtrx.py @@ -4,20 +4,37 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/cover.rfxtrx/ """ +import voluptuous as vol + import homeassistant.components.rfxtrx as rfxtrx -from homeassistant.components.cover import CoverDevice +from homeassistant.components.cover import CoverDevice, 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 DEPENDENCIES = ['rfxtrx'] -PLATFORM_SCHEMA = rfxtrx.DEFAULT_SCHEMA +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_AUTOMATIC_ADD, default=False): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS): + vol.Coerce(int), +}) -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the RFXtrx cover.""" import RFXtrx as rfxtrxmod covers = rfxtrx.get_devices_from_config(config, RfxtrxCover) - add_devices_callback(covers) + add_devices(covers) def cover_update(event): """Handle cover updates from the RFXtrx gateway.""" @@ -28,7 +45,7 @@ def cover_update(event): new_device = rfxtrx.get_new_device(event, config, RfxtrxCover) if new_device: - add_devices_callback([new_device]) + add_devices([new_device]) rfxtrx.apply_received_command(event) @@ -42,7 +59,7 @@ class RfxtrxCover(rfxtrx.RfxtrxDevice, CoverDevice): @property def should_poll(self): - """No polling available in RFXtrx cover.""" + """Return the polling state. No polling available in RFXtrx cover.""" return False @property diff --git a/homeassistant/components/cover/rpi_gpio.py b/homeassistant/components/cover/rpi_gpio.py index 1ee3ea00476a7..49666139330c5 100644 --- a/homeassistant/components/cover/rpi_gpio.py +++ b/homeassistant/components/cover/rpi_gpio.py @@ -87,12 +87,7 @@ def __init__(self, name, relay_pin, state_pin, state_pull_mode, 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) - - @property - def unique_id(self): - """Return the ID of this cover.""" - return '{}.{}'.format(self.__class__, self._name) + rpi_gpio.write_output(self._relay_pin, 0 if self._invert_relay else 1) @property def name(self): @@ -110,16 +105,16 @@ def is_closed(self): 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): + def close_cover(self, **kwargs): """Close the cover.""" if not self.is_closed: self._trigger() - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" if self.is_closed: self._trigger() diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml index 02765ca9ab883..79f00180a8946 100644 --- a/homeassistant/components/cover/services.yaml +++ b/homeassistant/components/cover/services.yaml @@ -1,71 +1,63 @@ -open_cover: - description: Open all or specified cover - - fields: - entity_id: - description: Name(s) of cover(s) to open - example: 'cover.living_room' - -close_cover: - description: Close all or specified cover - - fields: - entity_id: - description: Name(s) of cover(s) to close - example: 'cover.living_room' - -set_cover_position: - description: Move to specific position all or specified cover - - fields: - entity_id: - description: Name(s) of cover(s) to set cover position - example: 'cover.living_room' - - position: - description: Position of the cover (0 to 100) - example: 30 - -stop_cover: - description: Stop all or specified cover - - fields: - entity_id: - description: Name(s) of cover(s) to stop - example: 'cover.living_room' - -open_cover_tilt: - description: Open all or specified cover tilt - - fields: - entity_id: - description: Name(s) of cover(s) tilt to open - example: 'cover.living_room' - -close_cover_tilt: - description: Close all or specified cover tilt - - fields: - entity_id: - description: Name(s) of cover(s) to close tilt - example: 'cover.living_room' - -set_cover_tilt_position: - description: Move to specific position all or specified cover tilt - - fields: - entity_id: - description: Name(s) of cover(s) to set cover tilt position - example: 'cover.living_room' - - position: - description: Position of the cover (0 to 100) - example: 30 - -stop_cover_tilt: - description: Stop all or specified cover - - fields: - entity_id: - description: Name(s) of cover(s) to stop - example: 'cover.living_room' +# Describes the format for available cover services + +open_cover: + description: Open all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to open. + example: 'cover.living_room' + +close_cover: + description: Close all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to close. + example: 'cover.living_room' + +set_cover_position: + description: Move to specific position all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to set cover position. + example: 'cover.living_room' + position: + description: Position of the cover (0 to 100). + example: 30 + +stop_cover: + description: Stop all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to stop. + example: 'cover.living_room' + +open_cover_tilt: + description: Open all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) tilt to open. + example: 'cover.living_room' + +close_cover_tilt: + description: Close all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) to close tilt. + example: 'cover.living_room' + +set_cover_tilt_position: + description: Move to specific position all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) to set cover tilt position. + example: 'cover.living_room' + tilt_position: + description: Tilt position of the cover (0 to 100). + example: 30 + +stop_cover_tilt: + description: Stop all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to stop. + example: 'cover.living_room' diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py new file mode 100644 index 0000000000000..cf8b7dfad48c0 --- /dev/null +++ b/homeassistant/components/cover/tahoma.py @@ -0,0 +1,87 @@ +""" +Support for Tahoma cover - shutters etc. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.tahoma/ +""" +import logging + +from homeassistant.components.cover import CoverDevice, ATTR_POSITION +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 the Tahoma covers.""" + controller = hass.data[TAHOMA_DOMAIN]['controller'] + devices = [] + for device in hass.data[TAHOMA_DOMAIN]['devices']['cover']: + devices.append(TahomaCover(device, controller)) + add_devices(devices, True) + + +class TahomaCover(TahomaDevice, CoverDevice): + """Representation a Tahoma Cover.""" + + def update(self): + """Update method.""" + self.controller.get_states([self.tahoma_device]) + + @property + def current_cover_position(self): + """ + Return current position of cover. + + 0 is closed, 100 is fully open. + """ + try: + position = 100 - \ + self.tahoma_device.active_states['core:ClosureState'] + if position <= 5: + return 0 + if position >= 95: + return 100 + return position + except KeyError: + return None + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + self.apply_action('setPosition', 100 - kwargs.get(ATTR_POSITION)) + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is not None: + return self.current_cover_position == 0 + + @property + def device_class(self): + """Return the class of the device.""" + if self.tahoma_device.type == 'io:WindowOpenerVeluxIOComponent': + return 'window' + return None + + def open_cover(self, **kwargs): + """Open the cover.""" + self.apply_action('open') + + def close_cover(self, **kwargs): + """Close the cover.""" + self.apply_action('close') + + def stop_cover(self, **kwargs): + """Stop the cover.""" + 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/tellstick.py b/homeassistant/components/cover/tellstick.py new file mode 100644 index 0000000000000..56a5a24b4095a --- /dev/null +++ b/homeassistant/components/cover/tellstick.py @@ -0,0 +1,65 @@ +""" +Support for Tellstick covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.tellstick/ +""" + + +from homeassistant.components.cover import CoverDevice +from homeassistant.components.tellstick import ( + DEFAULT_SIGNAL_REPETITIONS, ATTR_DISCOVER_DEVICES, ATTR_DISCOVER_CONFIG, + DATA_TELLSTICK, TellstickDevice) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tellstick covers.""" + if (discovery_info is None or + discovery_info[ATTR_DISCOVER_DEVICES] is None): + return + + signal_repetitions = discovery_info.get( + ATTR_DISCOVER_CONFIG, DEFAULT_SIGNAL_REPETITIONS) + + add_devices([TellstickCover(hass.data[DATA_TELLSTICK][tellcore_id], + signal_repetitions) + for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]], + True) + + +class TellstickCover(TellstickDevice, CoverDevice): + """Representation of a Tellstick cover.""" + + @property + def is_closed(self): + """Return the current position of the cover is not possible.""" + return None + + @property + def assumed_state(self): + """Return True if unable to access real state of the entity.""" + return True + + def close_cover(self, **kwargs): + """Close the cover.""" + self._tellcore_device.down() + + def open_cover(self, **kwargs): + """Open the cover.""" + self._tellcore_device.up() + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._tellcore_device.stop() + + def _parse_tellcore_data(self, tellcore_data): + """Turn the value received from tellcore into something useful.""" + pass + + def _parse_ha_data(self, kwargs): + """Turn the value from HA into something useful.""" + pass + + def _update_model(self, new_state, data): + """Update the device entity state to match the arguments.""" + pass diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index f9e059d392788..4e197365a7098 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -19,12 +19,12 @@ CONF_FRIENDLY_NAME, CONF_ENTITY_ID, EVENT_HOMEASSISTANT_START, MATCH_ALL, CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, - CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED) + CONF_ENTITY_PICTURE_TEMPLATE, CONF_OPTIMISTIC, + STATE_OPEN, STATE_CLOSED) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.event import async_track_state_change -from homeassistant.helpers.restore_state import async_get_last_state from homeassistant.helpers.script import Script _LOGGER = logging.getLogger(__name__) @@ -58,11 +58,12 @@ vol.Optional(CONF_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_TEMPLATE): cv.template, vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, vol.Optional(CONF_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_FRIENDLY_NAME, default=None): cv.string, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_ENTITY_ID): cv.entity_ids }) @@ -82,6 +83,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): position_template = device_config.get(CONF_POSITION_TEMPLATE) tilt_template = device_config.get(CONF_TILT_TEMPLATE) icon_template = device_config.get(CONF_ICON_TEMPLATE) + entity_picture_template = device_config.get( + CONF_ENTITY_PICTURE_TEMPLATE) open_action = device_config.get(OPEN_ACTION) close_action = device_config.get(CLOSE_ACTION) stop_action = device_config.get(STOP_ACTION) @@ -115,6 +118,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if str(temp_ids) != MATCH_ALL: template_entity_ids |= set(temp_ids) + if entity_picture_template is not None: + temp_ids = entity_picture_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + if not template_entity_ids: template_entity_ids = MATCH_ALL @@ -125,8 +133,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hass, device, friendly_name, state_template, position_template, tilt_template, icon_template, - open_action, close_action, stop_action, - position_action, tilt_action, + entity_picture_template, open_action, close_action, + stop_action, position_action, tilt_action, optimistic, tilt_optimistic, entity_ids ) ) @@ -134,7 +142,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.error("No covers added") return False - async_add_devices(covers, True) + async_add_devices(covers) return True @@ -143,8 +151,8 @@ class CoverTemplate(CoverDevice): def __init__(self, hass, device_id, friendly_name, state_template, position_template, tilt_template, icon_template, - open_action, close_action, stop_action, - position_action, tilt_action, + entity_picture_template, open_action, close_action, + stop_action, position_action, tilt_action, optimistic, tilt_optimistic, entity_ids): """Initialize the Template cover.""" self.hass = hass @@ -155,6 +163,7 @@ def __init__(self, hass, device_id, friendly_name, state_template, self._position_template = position_template self._tilt_template = tilt_template self._icon_template = icon_template + self._entity_picture_template = entity_picture_template self._open_script = None if open_action is not None: self._open_script = Script(hass, open_action) @@ -174,6 +183,7 @@ def __init__(self, hass, device_id, friendly_name, state_template, (not state_template and not position_template)) self._tilt_optimistic = tilt_optimistic or not tilt_template self._icon = None + self._entity_picture = None self._position = None self._tilt_value = None self._entities = entity_ids @@ -186,18 +196,16 @@ def __init__(self, hass, device_id, friendly_name, state_template, self._tilt_template.hass = self.hass if self._icon_template is not None: self._icon_template.hass = self.hass + if self._entity_picture_template is not None: + self._entity_picture_template.hass = self.hass @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - state = yield from async_get_last_state(self.hass, self.entity_id) - if state: - self._position = 100 if state.state == STATE_OPEN else 0 - @callback def template_cover_state_listener(entity, old_state, new_state): """Handle target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @callback def template_cover_startup(event): @@ -205,7 +213,7 @@ def template_cover_startup(event): async_track_state_change( self.hass, self._entities, template_cover_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_cover_startup) @@ -226,7 +234,9 @@ def current_cover_position(self): 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): @@ -241,6 +251,11 @@ def icon(self): """Return the icon to use in the frontend, if any.""" return self._icon + @property + def entity_picture(self): + """Return the entity picture to use in the frontend, if any.""" + return self._entity_picture + @property def supported_features(self): """Flag supported features.""" @@ -271,7 +286,7 @@ def async_open_cover(self, **kwargs): yield from self._position_script.async_run({"position": 100}) if self._optimistic: self._position = 100 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover(self, **kwargs): @@ -282,13 +297,13 @@ def async_close_cover(self, **kwargs): yield from self._position_script.async_run({"position": 0}) if self._optimistic: self._position = 0 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_stop_cover(self, **kwargs): """Fire the stop action.""" if self._stop_script: - self.hass.async_add_job(self._stop_script.async_run()) + yield from self._stop_script.async_run() @asyncio.coroutine def async_set_cover_position(self, **kwargs): @@ -297,7 +312,7 @@ def async_set_cover_position(self, **kwargs): yield from self._position_script.async_run( {"position": self._position}) if self._optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_open_cover_tilt(self, **kwargs): @@ -305,7 +320,7 @@ def async_open_cover_tilt(self, **kwargs): self._tilt_value = 100 yield from self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover_tilt(self, **kwargs): @@ -314,7 +329,7 @@ def async_close_cover_tilt(self, **kwargs): yield from self._tilt_script.async_run( {"tilt": self._tilt_value}) if self._tilt_optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): @@ -322,7 +337,7 @@ def async_set_cover_tilt_position(self, **kwargs): self._tilt_value = kwargs[ATTR_TILT_POSITION] yield from self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_update(self): @@ -374,16 +389,28 @@ def async_update(self): except ValueError as ex: _LOGGER.error(ex) self._tilt_value = None - if self._icon_template is not None: + + for property_name, template in ( + ('_icon', self._icon_template), + ('_entity_picture', self._entity_picture_template)): + if template is None: + continue + try: - self._icon = self._icon_template.async_render() + setattr(self, property_name, template.async_render()) except TemplateError as ex: + friendly_property_name = property_name[1:].replace('_', ' ') if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): # Common during HA startup - so just a warning - _LOGGER.warning('Could not render icon template %s,' - ' the state is unknown.', self._name) + _LOGGER.warning('Could not render %s template %s,' + ' the state is unknown.', + friendly_property_name, self._name) return - self._icon = super().icon - _LOGGER.error('Could not render icon template %s: %s', - self._name, ex) + + try: + setattr(self, property_name, + getattr(super(), property_name)) + except AttributeError: + _LOGGER.error('Could not render %s template %s: %s', + friendly_property_name, self._name, ex) diff --git a/homeassistant/components/cover/vera.py b/homeassistant/components/cover/vera.py index 05be125ec6f95..ff9ba6f762b86 100644 --- a/homeassistant/components/cover/vera.py +++ b/homeassistant/components/cover/vera.py @@ -6,7 +6,8 @@ """ import logging -from homeassistant.components.cover import CoverDevice, ENTITY_ID_FORMAT +from homeassistant.components.cover import CoverDevice, ENTITY_ID_FORMAT, \ + ATTR_POSITION from homeassistant.components.vera import ( VERA_CONTROLLER, VERA_DEVICES, VeraDevice) @@ -18,8 +19,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera covers.""" add_devices( - VeraCover(device, VERA_CONTROLLER) for - device in VERA_DEVICES['cover']) + VeraCover(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['cover']) class VeraCover(VeraDevice, CoverDevice): @@ -44,9 +45,9 @@ def current_cover_position(self): return 100 return position - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" - self.vera_device.set_level(position) + self.vera_device.set_level(kwargs.get(ATTR_POSITION)) self.schedule_update_ha_state() @property diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py index ce96b4d75e0e3..093ccd43473a5 100644 --- a/homeassistant/components/cover/wink.py +++ b/homeassistant/components/cover/wink.py @@ -6,7 +6,8 @@ """ import asyncio -from homeassistant.components.cover import CoverDevice +from homeassistant.components.cover import CoverDevice, STATE_UNKNOWN, \ + ATTR_POSITION from homeassistant.components.wink import WinkDevice, DOMAIN DEPENDENCIES = ['wink'] @@ -31,25 +32,28 @@ class WinkCoverDevice(WinkDevice, CoverDevice): @asyncio.coroutine def async_added_to_hass(self): - """Callback when entity is added to hass.""" + """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['cover'].append(self) def close_cover(self, **kwargs): - """Close the shade.""" + """Close the cover.""" self.wink.set_state(0) def open_cover(self, **kwargs): - """Open the shade.""" + """Open the cover.""" self.wink.set_state(1) - def set_cover_position(self, position, **kwargs): - """Move the roller shutter to a specific position.""" - self.wink.set_state(float(position)/100) + def set_cover_position(self, **kwargs): + """Move the cover shutter to a specific position.""" + position = kwargs.get(ATTR_POSITION) + self.wink.set_state(position/100) @property def current_cover_position(self): - """Return the current position of roller shutter.""" - return int(self.wink.state()*100) + """Return the current position of cover shutter.""" + if self.wink.state() is not None: + return int(self.wink.state()*100) + return STATE_UNKNOWN @property def is_closed(self): diff --git a/homeassistant/components/cover/xiaomi.py b/homeassistant/components/cover/xiaomi_aqara.py similarity index 84% rename from homeassistant/components/cover/xiaomi.py rename to homeassistant/components/cover/xiaomi_aqara.py index d0e7bfa6d7eb7..14321149148a5 100644 --- a/homeassistant/components/cover/xiaomi.py +++ b/homeassistant/components/cover/xiaomi_aqara.py @@ -1,8 +1,9 @@ """Support for Xiaomi curtain.""" import logging -from homeassistant.components.cover import CoverDevice -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.cover import CoverDevice, ATTR_POSITION +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) _LOGGER = logging.getLogger(__name__) @@ -40,7 +41,7 @@ def current_cover_position(self): @property def is_closed(self): """Return if the cover is closed.""" - return self.current_cover_position < 0 + return self.current_cover_position <= 0 def close_cover(self, **kwargs): """Close the cover.""" @@ -54,11 +55,12 @@ def stop_cover(self, **kwargs): """Stop the cover.""" self._write_to_hub(self._sid, **{self._data_key['status']: 'stop'}) - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" + position = kwargs.get(ATTR_POSITION) self._write_to_hub(self._sid, **{self._data_key['pos']: str(position)}) - def parse_data(self, data): + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" if ATTR_CURTAIN_LEVEL in data: self._pos = int(data[ATTR_CURTAIN_LEVEL]) diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 3c03812561679..6f4a11684bde6 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -8,7 +8,7 @@ # pylint: disable=import-error import logging from homeassistant.components.cover import ( - DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE) + DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION) from homeassistant.components.zwave import ZWaveDeviceEntity from homeassistant.components import zwave from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import @@ -97,9 +97,10 @@ def close_cover(self, **kwargs): """Move the roller shutter down.""" self._network.manager.pressButton(self._close_id) - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the roller shutter to a specific position.""" - self.node.set_dimmer(self.values.primary.value_id, position) + self.node.set_dimmer(self.values.primary.value_id, + kwargs.get(ATTR_POSITION)) def stop_cover(self, **kwargs): """Stop the roller shutter.""" @@ -139,11 +140,11 @@ def is_closed(self): """Return the current position of Zwave garage door.""" return not self._state - def close_cover(self): + def close_cover(self, **kwargs): """Close the garage door.""" self.values.primary.data = False - def open_cover(self): + def open_cover(self, **kwargs): """Open the garage door.""" self.values.primary.data = True @@ -158,7 +159,7 @@ def is_opening(self): @property def is_closing(self): - """Return true if cover is in an closing state.""" + """Return true if cover is in a closing state.""" return self._state == "Closing" @property @@ -166,10 +167,10 @@ def is_closed(self): """Return the current position of Zwave garage door.""" return self._state == "Closed" - def close_cover(self): + def close_cover(self, **kwargs): """Close the garage door.""" self.values.primary.data = "Closed" - def open_cover(self): + def open_cover(self, **kwargs): """Open the garage door.""" self.values.primary.data = "Opened" diff --git a/homeassistant/components/daikin.py b/homeassistant/components/daikin.py new file mode 100644 index 0000000000000..5808528ca5adf --- /dev/null +++ b/homeassistant/components/daikin.py @@ -0,0 +1,138 @@ +""" +Platform for the Daikin AC. + +For more details about this component, please refer to the documentation +https://home-assistant.io/components/daikin/ +""" +import logging +from datetime import timedelta +from socket import timeout + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.discovery import SERVICE_DAIKIN +from homeassistant.const import ( + CONF_HOSTS, CONF_ICON, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_TYPE +) +from homeassistant.helpers import discovery +from homeassistant.helpers.discovery import load_platform +from homeassistant.util import Throttle + +REQUIREMENTS = ['pydaikin==0.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'daikin' +HTTP_RESOURCES = ['aircon/get_sensor_info', 'aircon/get_control_info'] + +ATTR_TARGET_TEMPERATURE = 'target_temperature' +ATTR_INSIDE_TEMPERATURE = 'inside_temperature' +ATTR_OUTSIDE_TEMPERATURE = 'outside_temperature' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +COMPONENT_TYPES = ['climate', 'sensor'] + +SENSOR_TYPE_TEMPERATURE = 'temperature' + +SENSOR_TYPES = { + ATTR_INSIDE_TEMPERATURE: { + CONF_NAME: 'Inside Temperature', + CONF_ICON: 'mdi:thermometer', + CONF_TYPE: SENSOR_TYPE_TEMPERATURE + }, + ATTR_OUTSIDE_TEMPERATURE: { + CONF_NAME: 'Outside Temperature', + CONF_ICON: 'mdi:thermometer', + CONF_TYPE: SENSOR_TYPE_TEMPERATURE + } + +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional( + CONF_HOSTS, default=[] + ): vol.All(cv.ensure_list, [cv.string]), + vol.Optional( + CONF_MONITORED_CONDITIONS, + default=list(SENSOR_TYPES.keys()) + ): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]) + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Establish connection with Daikin.""" + def discovery_dispatch(service, discovery_info): + """Dispatcher for Daikin discovery events.""" + host = discovery_info.get('ip') + + if daikin_api_setup(hass, host) is None: + return + + for component in COMPONENT_TYPES: + load_platform(hass, component, DOMAIN, discovery_info, + config) + + discovery.listen(hass, SERVICE_DAIKIN, discovery_dispatch) + + for host in config.get(DOMAIN, {}).get(CONF_HOSTS, []): + if daikin_api_setup(hass, host) is None: + continue + + discovery_info = { + 'ip': host, + CONF_MONITORED_CONDITIONS: + config[DOMAIN][CONF_MONITORED_CONDITIONS] + } + load_platform(hass, 'sensor', DOMAIN, discovery_info, config) + + return True + + +def daikin_api_setup(hass, host, name=None): + """Create a Daikin instance only once.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + api = hass.data[DOMAIN].get(host) + if api is None: + from pydaikin import appliance + + try: + device = appliance.Appliance(host) + except timeout: + _LOGGER.error("Connection to Daikin could not be established") + return False + + if name is None: + name = device.values['name'] + + api = DaikinApi(device, name) + + return api + + +class DaikinApi(object): + """Keep the Daikin instance in one place and centralize the update.""" + + def __init__(self, device, name): + """Initialize the Daikin Handle.""" + self.device = device + self.name = name + self.ip_address = device.ip + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Pull the latest data from Daikin.""" + try: + for resource in HTTP_RESOURCES: + self.device.values.update( + self.device.get_resource(resource) + ) + except timeout: + _LOGGER.warning( + "Connection failed for %s", self.ip_address + ) diff --git a/homeassistant/components/datadog.py b/homeassistant/components/datadog.py index 2c8145177b77f..58503d7187b37 100644 --- a/homeassistant/components/datadog.py +++ b/homeassistant/components/datadog.py @@ -5,11 +5,12 @@ https://home-assistant.io/components/datadog/ """ import logging + import voluptuous as vol -from homeassistant.const import (CONF_HOST, CONF_PORT, CONF_PREFIX, - EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, - STATE_UNKNOWN) +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_LOGBOOK_ENTRY, + EVENT_STATE_CHANGED, STATE_UNKNOWN) from homeassistant.helpers import state as state_helper import homeassistant.helpers.config_validation as cv @@ -36,7 +37,7 @@ def setup(hass, config): - """Setup the Datadog component.""" + """Set up the Datadog component.""" from datadog import initialize, statsd conf = config[DOMAIN] @@ -81,36 +82,19 @@ def state_changed_listener(event): if isinstance(value, (float, int)): attribute = "{}.{}".format(metric, key.replace(' ', '_')) statsd.gauge( - attribute, - value, - sample_rate=sample_rate, - tags=tags - ) + attribute, value, sample_rate=sample_rate, tags=tags) _LOGGER.debug( - 'Sent metric %s: %s (tags: %s)', - attribute, - value, - tags - ) + "Sent metric %s: %s (tags: %s)", attribute, value, tags) try: value = state_helper.state_as_number(state) except ValueError: _LOGGER.debug( - 'Error sending %s: %s (tags: %s)', - metric, - state.state, - tags - ) + "Error sending %s: %s (tags: %s)", metric, state.state, tags) return - statsd.gauge( - metric, - value, - sample_rate=sample_rate, - tags=tags - ) + statsd.gauge(metric, value, sample_rate=sample_rate, tags=tags) _LOGGER.debug('Sent metric %s: %s (tags: %s)', metric, value, tags) diff --git a/homeassistant/components/deconz/.translations/bg.json b/homeassistant/components/deconz/.translations/bg.json new file mode 100644 index 0000000000000..91727cae25700 --- /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 0000000000000..fff54bb3f6cfd --- /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 0000000000000..698f55c59ec7a --- /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 0000000000000..9d3dc9e6e62f4 --- /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 0000000000000..0009986d45f48 --- /dev/null +++ b/homeassistant/components/deconz/.translations/en.json @@ -0,0 +1,26 @@ +{ + "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" + } + }, + "title": "deCONZ" + } +} \ 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 0000000000000..42aab9c6d7e56 --- /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 0000000000000..d6de1028218de --- /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 0000000000000..2a9dfc5e5438d --- /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 0000000000000..90d13bb39b470 --- /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 0000000000000..25e3b0b7d68c4 --- /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 0000000000000..bb7488fcbec1e --- /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 0000000000000..2a00c69869140 --- /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 0000000000000..b0dc6a8a4a85f --- /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 0000000000000..b738002b273d6 --- /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 0000000000000..f41b5b5111c2b --- /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 0000000000000..33be3846eb829 --- /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 new file mode 100644 index 0000000000000..bbab4029d7edf --- /dev/null +++ b/homeassistant/components/deconz/__init__.py @@ -0,0 +1,199 @@ +""" +Support for deCONZ devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/deconz/ +""" +import voluptuous as vol + +from homeassistant.const import ( + 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 + +# Loading the config flow file will register the flow +from .config_flow import configured_hosts +from .const import ( + CONFIG_FILE, DATA_DECONZ_EVENT, DATA_DECONZ_ID, + DATA_DECONZ_UNSUB, DOMAIN, _LOGGER) + +REQUIREMENTS = ['pydeconz==38'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_API_KEY): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=80): cv.port, + }) +}, extra=vol.ALLOW_EXTRA) + +SERVICE_DECONZ = 'configure' + +SERVICE_FIELD = 'field' +SERVICE_ENTITY = 'entity' +SERVICE_DATA = 'data' + +SERVICE_SCHEMA = vol.Schema({ + vol.Exclusive(SERVICE_FIELD, 'deconz_id'): cv.string, + vol.Exclusive(SERVICE_ENTITY, 'deconz_id'): cv.entity_id, + vol.Required(SERVICE_DATA): dict, +}) + + +async def async_setup(hass, config): + """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_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. + """ + from pydeconz import DeconzSession + 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") + return False + + 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(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 + for sensor in sensors: + if sensor.type in DECONZ_REMOTE: + 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): + """Set attribute of device in deCONZ. + + Field is a string representing a specific device in deCONZ + e.g. field='/lights/1/state'. + Entity_id can be used to retrieve the proper field. + Data is a json object with what data you want to alter + e.g. data={'on': true}. + { + "field": "/lights/1/state", + "data": {"on": true} + } + See Dresden Elektroniks REST API documentation for details: + http://dresden-elektronik.github.io/deconz-rest-doc/rest/ + """ + field = call.data.get(SERVICE_FIELD) + entity_id = call.data.get(SERVICE_ENTITY) + data = call.data.get(SERVICE_DATA) + deconz = hass.data[DOMAIN] + if entity_id: + entities = hass.data.get(DATA_DECONZ_ID) + if entities: + field = entities.get(entity_id) + if field is None: + _LOGGER.error('Could not find the entity %s', entity_id) + return + await deconz.async_put_state(field, data) + hass.services.async_register( + DOMAIN, SERVICE_DECONZ, async_configure, schema=SERVICE_SCHEMA) + + @callback + def deconz_shutdown(event): + """ + Wrap the call to deconz.close. + + Used as an argument to EventBus.async_listen_once - EventBus calls + this method with the event as the first argument, which should not + be passed on to deconz.close. + """ + deconz.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, deconz_shutdown) + return True + + +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 + + +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/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py new file mode 100644 index 0000000000000..e900782ea658d --- /dev/null +++ b/homeassistant/components/deconz/config_flow.py @@ -0,0 +1,139 @@ +"""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 CONFIG_FILE, DOMAIN + + +@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)) + + +@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.""" + 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, async_get_bridgeid + 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 + if 'bridgeid' not in self.deconz_config: + self.deconz_config['bridgeid'] = await async_get_bridgeid( + session, **self.deconz_config) + return self.async_create_entry( + title='deCONZ-' + self.deconz_config['bridgeid'], + data=self.deconz_config + ) + errors['base'] = 'no_key' + + return self.async_show_form( + step_id='link', + errors=errors, + ) + + 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['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. + """ + from pydeconz.utils import async_get_bridgeid + + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + elif CONF_API_KEY not in import_config: + self.deconz_config = import_config + return await self.async_step_link() + + if 'bridgeid' not in import_config: + session = aiohttp_client.async_get_clientsession(self.hass) + import_config['bridgeid'] = await async_get_bridgeid( + session, **import_config) + return self.async_create_entry( + title='deCONZ-' + import_config['bridgeid'], + data=import_config + ) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py new file mode 100644 index 0000000000000..48e5ea75d684c --- /dev/null +++ b/homeassistant/components/deconz/const.py @@ -0,0 +1,10 @@ +"""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' diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml new file mode 100644 index 0000000000000..78bf7041a9329 --- /dev/null +++ b/homeassistant/components/deconz/services.yaml @@ -0,0 +1,13 @@ + +configure: + description: Set attribute of device in deCONZ. See https://home-assistant.io/components/deconz/#device-services for details. + fields: + field: + description: Field is a string representing a specific device in deCONZ. + example: '/lights/1/state' + entity: + description: Entity id representing a specific device in deCONZ. + example: 'light.rgb_light' + data: + description: Data is a json object with what data you want to alter. + example: '{"on": true}' diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json new file mode 100644 index 0000000000000..7ea68af01c1dc --- /dev/null +++ b/homeassistant/components/deconz/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "title": "deCONZ", + "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" + } + }, + "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/demo.py b/homeassistant/components/demo.py index 2f1dde05bab4f..64ce3cda07301 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -87,8 +87,8 @@ def async_setup(hass, config): # Set up input boolean tasks.append(bootstrap.async_setup_component( - hass, 'input_slider', - {'input_slider': { + hass, 'input_number', + {'input_number': { 'noise_allowance': {'icon': 'mdi:bell-ring', 'min': 0, 'max': 10, @@ -118,6 +118,17 @@ def async_setup(hass, config): tasks2 = [] + # Set up history graph + tasks2.append(bootstrap.async_setup_component( + hass, 'history_graph', + {'history_graph': {'switches': { + 'name': 'Recent Switches', + 'entities': switches, + 'hours_to_show': 1, + 'refresh': 60 + }}} + )) + # Set up scripts tasks2.append(bootstrap.async_setup_component( hass, 'script', @@ -163,7 +174,7 @@ def async_setup(hass, config): 'scene.romantic_lights'])) tasks2.append(group.Group.async_create_group(hass, 'Bedroom', [ lights[0], switches[1], media_players[0], - 'input_slider.noise_allowance'])) + 'input_number.noise_allowance'])) tasks2.append(group.Group.async_create_group(hass, 'Kitchen', [ lights[2], 'cover.kitchen_window', 'lock.kitchen_door'])) tasks2.append(group.Group.async_create_group(hass, 'Doors', [ diff --git a/homeassistant/components/device_sun_light_trigger.py b/homeassistant/components/device_sun_light_trigger.py index a1297c5c11895..641ade7308b82 100644 --- a/homeassistant/components/device_sun_light_trigger.py +++ b/homeassistant/components/device_sun_light_trigger.py @@ -16,7 +16,6 @@ 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 @@ 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 calc_time_for_light_when_sunset(): 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 schedule_light_turn_on(now): @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 check_light_on_dev_state_change(entity, old_state, new_state): # 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 check_light_on_dev_state_change(entity, old_state, new_state): # 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 check_light_on_dev_state_change(entity, old_state, new_state): @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 8192dfa751de9..580c0272e469d 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -7,30 +7,25 @@ import asyncio from datetime import timedelta import logging -import os 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.discovery import SERVICE_NETGEAR +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 +from homeassistant.util.async_ import run_coroutine_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.yaml import dump @@ -38,7 +33,7 @@ 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__) @@ -54,6 +49,7 @@ CONF_TRACK_NEW = 'track_new_devices' DEFAULT_TRACK_NEW = True +CONF_NEW_DEVICE_DEFAULTS = 'new_device_defaults' CONF_CONSIDER_HOME = 'consider_home' DEFAULT_CONSIDER_HOME = timedelta(seconds=180) @@ -75,37 +71,59 @@ ATTR_HOST_NAME = 'host_name' ATTR_LOCATION_NAME = 'location_name' ATTR_MAC = 'mac' -ATTR_NAME = 'name' ATTR_SOURCE_TYPE = 'source_type' +ATTR_CONSIDER_HOME = 'consider_home' SOURCE_TYPE_GPS = 'gps' SOURCE_TYPE_ROUTER = 'router' +SOURCE_TYPE_BLUETOOTH = 'bluetooth' +SOURCE_TYPE_BLUETOOTH_LE = 'bluetooth_le' +SOURCE_TYPES = (SOURCE_TYPE_GPS, SOURCE_TYPE_ROUTER, + SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE) +NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(None, vol.Schema({ + vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, + vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, +})) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, - vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, + vol.Optional(CONF_TRACK_NEW): cv.boolean, vol.Optional(CONF_CONSIDER_HOME, default=DEFAULT_CONSIDER_HOME): vol.All( - cv.time_period, cv.positive_timedelta) + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_NEW_DEVICE_DEFAULTS, + default={}): NEW_DEVICE_DEFAULTS_SCHEMA }) - -DISCOVERY_PLATFORMS = { - SERVICE_NETGEAR: 'netgear', -} +SERVICE_SEE_PAYLOAD_SCHEMA = vol.Schema(vol.All( + cv.has_at_least_one_key(ATTR_MAC, ATTR_DEV_ID), { + ATTR_MAC: cv.string, + ATTR_DEV_ID: cv.string, + ATTR_HOST_NAME: cv.string, + ATTR_LOCATION_NAME: cv.string, + ATTR_GPS: cv.gps, + ATTR_GPS_ACCURACY: cv.positive_int, + ATTR_BATTERY: cv.positive_int, + ATTR_ATTRIBUTES: dict, + ATTR_SOURCE_TYPE: vol.In(SOURCE_TYPES), + ATTR_CONSIDER_HOME: cv.time_period, + # Temp workaround for iOS app introduced in 0.65 + vol.Optional('battery_status'): str, + vol.Optional('hostname'): str, + })) @bind_hass -def is_on(hass: HomeAssistantType, entity_id: str=None): +def is_on(hass: HomeAssistantType, entity_id: str = None): """Return the state if any or a specified device is home.""" entity = entity_id or ENTITY_ID_ALL_DEVICES return hass.states.is_state(entity, STATE_HOME) -def see(hass: HomeAssistantType, mac: str=None, dev_id: str=None, - host_name: str=None, location_name: str=None, - gps: GPSType=None, gps_accuracy=None, - battery=None, attributes: dict=None): +def see(hass: HomeAssistantType, mac: str = None, dev_id: str = None, + host_name: str = None, location_name: str = None, + gps: GPSType = None, gps_accuracy=None, + battery: int = None, attributes: dict = None): """Call service to notify you see device.""" data = {key: value for key, value in ((ATTR_MAC, mac), @@ -128,10 +146,15 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): conf = config.get(DOMAIN, []) conf = conf[0] if conf else {} consider_home = conf.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME) - track_new = conf.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + + defaults = conf.get(CONF_NEW_DEVICE_DEFAULTS, {}) + track_new = conf.get(CONF_TRACK_NEW) + if track_new is None: + track_new = defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) devices = yield from async_load_config(yaml_path, hass, consider_home) - tracker = DeviceTracker(hass, consider_home, track_new, devices) + tracker = DeviceTracker( + hass, consider_home, track_new, defaults, devices) @asyncio.coroutine def async_setup_platform(p_type, p_config, disc_info=None): @@ -180,15 +203,6 @@ def async_setup_platform(p_type, p_config, disc_info=None): tracker.async_setup_group() - @callback - def async_device_tracker_discovered(service, info): - """Handle the discovery of device tracker platforms.""" - hass.async_add_job( - async_setup_platform(DISCOVERY_PLATFORMS[service], {}, info)) - - discovery.async_listen( - hass, DISCOVERY_PLATFORMS.keys(), async_device_tracker_discovered) - @asyncio.coroutine def async_platform_discovered(platform, info): """Load a platform.""" @@ -203,17 +217,14 @@ def async_platform_discovered(platform, info): @asyncio.coroutine def async_see_service(call): """Service to see a device.""" - args = {key: value for key, value in call.data.items() if key in - (ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_LOCATION_NAME, - ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY, ATTR_ATTRIBUTES)} - yield from tracker.async_see(**args) - - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml') - ) + # Temp workaround for iOS, introduced in 0.65 + data = dict(call.data) + data.pop('hostname', None) + data.pop('battery_status', None) + yield from tracker.async_see(**data) + hass.services.async_register( - DOMAIN, SERVICE_SEE, async_see_service, descriptions.get(SERVICE_SEE)) + DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA) # restore yield from tracker.async_setup_tracked_device() @@ -224,13 +235,16 @@ class DeviceTracker(object): """Representation of a device tracker.""" def __init__(self, hass: HomeAssistantType, consider_home: timedelta, - track_new: bool, devices: Sequence) -> None: + track_new: bool, defaults: dict, + devices: Sequence) -> None: """Initialize a device tracker.""" self.hass = hass self.devices = {dev.dev_id: dev for dev in devices} self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac} self.consider_home = consider_home - self.track_new = track_new + self.track_new = track_new if track_new is not None \ + else defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + self.defaults = defaults self.group = None self._is_updating = asyncio.Lock(loop=hass.loop) @@ -241,24 +255,27 @@ def __init__(self, hass: HomeAssistantType, consider_home: timedelta, _LOGGER.warning('Duplicate device MAC addresses detected %s', dev.mac) - def see(self, mac: str=None, dev_id: str=None, host_name: str=None, - location_name: str=None, gps: GPSType=None, gps_accuracy=None, - battery: str=None, attributes: dict=None, - source_type: str=SOURCE_TYPE_GPS, picture: str=None, - icon: str=None): + def see(self, mac: str = None, dev_id: str = None, host_name: str = None, + location_name: str = None, gps: GPSType = None, + gps_accuracy: int = None, battery: int = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None, + consider_home: timedelta = None): """Notify the device tracker that you see a device.""" self.hass.add_job( self.async_see(mac, dev_id, host_name, location_name, gps, gps_accuracy, battery, attributes, source_type, - picture, icon) + picture, icon, consider_home) ) @asyncio.coroutine - def async_see(self, mac: str=None, dev_id: str=None, host_name: str=None, - location_name: str=None, gps: GPSType=None, - gps_accuracy=None, battery: str=None, attributes: dict=None, - source_type: str=SOURCE_TYPE_GPS, picture: str=None, - icon: str=None): + def async_see( + self, mac: str = None, dev_id: str = None, host_name: str = None, + location_name: str = None, gps: GPSType = None, + gps_accuracy: int = None, battery: int = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None, + consider_home: timedelta = None): """Notify the device tracker that you see a device. This method is a coroutine. @@ -277,7 +294,7 @@ def async_see(self, mac: str=None, dev_id: str=None, host_name: str=None, if device: yield from device.async_seen( host_name, location_name, gps, gps_accuracy, battery, - attributes, source_type) + attributes, source_type, consider_home) if device.track: yield from device.async_update_ha_state() return @@ -285,9 +302,10 @@ def async_see(self, mac: str=None, dev_id: str=None, host_name: str=None, # If no device can be found, create it dev_id = util.ensure_unique_string(dev_id, self.devices.keys()) device = Device( - self.hass, self.consider_home, self.track_new, + self.hass, consider_home or self.consider_home, self.track_new, dev_id, mac, (host_name or dev_id).replace('_', ' '), - picture=picture, icon=icon) + picture=picture, icon=icon, + hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) self.devices[dev_id] = device if mac is not None: self.mac_to_dev[mac] = device @@ -299,19 +317,17 @@ def async_see(self, mac: str=None, dev_id: str=None, host_name: str=None, if device.track: yield from device.async_update_ha_state() - self.hass.bus.async_fire(EVENT_NEW_DEVICE, { - ATTR_ENTITY_ID: device.entity_id, - ATTR_HOST_NAME: device.host_name, - }) - # 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, + }) # update known_devices.yaml self.hass.async_add_job( @@ -339,9 +355,9 @@ def async_setup_group(self): 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 @@ -383,11 +399,11 @@ class Device(Entity): host_name = None # type: str location_name = None # type: str gps = None # type: GPSType - gps_accuracy = 0 + gps_accuracy = 0 # type: int last_seen = None # type: dt_util.dt.datetime - battery = None # type: str + 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. @@ -395,9 +411,9 @@ class Device(Entity): _state = STATE_NOT_HOME 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: + track: bool, dev_id: str, mac: str, name: str = None, + picture: str = None, gravatar: str = None, icon: str = None, + hide_if_away: bool = False) -> None: """Initialize a device.""" self.hass = hass self.entity_id = ENTITY_ID_FORMAT.format(dev_id) @@ -425,7 +441,6 @@ def __init__(self, hass: HomeAssistantType, consider_home: timedelta, self.icon = icon self.away_hide = hide_if_away - self.vendor = vendor self.source_type = None @@ -474,14 +489,17 @@ def hidden(self): return self.away_hide and self.state != STATE_HOME @asyncio.coroutine - def async_seen(self, host_name: str=None, location_name: str=None, - gps: GPSType=None, gps_accuracy=0, battery: str=None, - attributes: dict=None, source_type: str=SOURCE_TYPE_GPS): + def async_seen(self, host_name: str = None, location_name: str = None, + gps: GPSType = None, gps_accuracy=0, battery: int = None, + attributes: dict = None, + source_type: str = SOURCE_TYPE_GPS, + consider_home: timedelta = None): """Mark the device as seen.""" self.source_type = source_type self.last_seen = dt_util.utcnow() self.host_name = host_name self.location_name = location_name + self.consider_home = consider_home or self.consider_home if battery: self.battery = battery @@ -503,7 +521,7 @@ def async_seen(self, host_name: str=None, location_name: str=None, # pylint: disable=not-an-iterable yield from self.async_update() - def stale(self, now: dt_util.dt.datetime=None): + def stale(self, now: dt_util.dt.datetime = None): """Return if device state is stale. Async friendly. @@ -522,7 +540,7 @@ def async_update(self): 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 @@ -538,51 +556,6 @@ def async_update(self): 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.""" @@ -620,16 +593,27 @@ def async_scan_devices(self) -> Any: """ return self.hass.async_add_job(self.scan_devices) - def get_device_name(self, mac: str) -> str: - """Get device name from mac.""" + def get_device_name(self, device: str) -> str: + """Get the name of a device.""" raise NotImplementedError() - def async_get_device_name(self, mac: str) -> Any: - """Get device name from mac. + def async_get_device_name(self, device: str) -> Any: + """Get the name of a device. This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(self.get_device_name, mac) + 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): @@ -647,8 +631,7 @@ def async_load_config(path: str, hass: HomeAssistantType, """ dev_schema = vol.Schema({ vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ICON, default=False): - vol.Any(None, cv.icon), + vol.Optional(CONF_ICON, default=None): vol.Any(None, cv.icon), vol.Optional('track', default=False): cv.boolean, vol.Optional(CONF_MAC, default=None): vol.Any(None, vol.All(cv.string, vol.Upper)), @@ -657,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 = [] @@ -669,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) @@ -716,10 +700,20 @@ def async_device_tracker_scan(now: dt_util.dt.datetime): 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) @@ -744,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/actiontec.py b/homeassistant/components/device_tracker/actiontec.py index 64e1a60ad0867..781e486a40e83 100644 --- a/homeassistant/components/device_tracker/actiontec.py +++ b/homeassistant/components/device_tracker/actiontec.py @@ -42,7 +42,7 @@ def get_scanner(hass, config): class ActiontecDeviceScanner(DeviceScanner): - """This class queries a an actiontec router for connected devices.""" + """This class queries an actiontec router for connected devices.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index cef5eabd90158..79d8806fe22bb 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -19,9 +19,9 @@ REQUIREMENTS = ['pexpect==4.0.1'] _DEVICES_REGEX = re.compile( - r'(?P([^\s]+))\s+' + + r'(?P([^\s]+)?)\s+' + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' + - r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+') + r'(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))\s+') PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 9b214441ac965..7e9b10e9241aa 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -12,23 +12,21 @@ import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) -import homeassistant.helpers.config_validation as cv + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, + CONF_PROTOCOL) REQUIREMENTS = ['pexpect==4.0.1'] _LOGGER = logging.getLogger(__name__) -CONF_MODE = 'mode' -CONF_PROTOCOL = 'protocol' CONF_PUB_KEY = 'pub_key' CONF_SSH_KEY = 'ssh_key' - +CONF_REQUIRE_IP = 'require_ip' DEFAULT_SSH_PORT = 22 - SECRET_GROUP = 'Password or SSH Key' PLATFORM_SCHEMA = vol.All( @@ -36,11 +34,10 @@ PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_PROTOCOL, default='ssh'): - vol.In(['ssh', 'telnet']), - vol.Optional(CONF_MODE, default='router'): - vol.In(['router', 'ap']), + vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']), + vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']), vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port, + vol.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 @@ -68,8 +65,18 @@ r'\w+\s' r'(\w+\s(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s' r'\s?(router)?' + r'\s?(nud)?' r'(?P(\w+))') +_ARP_CMD = 'arp -n' +_ARP_REGEX = re.compile( + r'.+\s' + + r'\((?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\)\s' + + r'.+\s' + + r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))' + + r'\s' + + r'.*') + # pylint: disable=unused-argument def get_scanner(hass, config): @@ -79,7 +86,22 @@ def get_scanner(hass, config): return scanner if scanner.success_init else None -AsusWrtResult = namedtuple('AsusWrtResult', 'neighbors leases') +def _parse_lines(lines, regex): + """Parse the lines using the given regular expression. + + If a line can't be parsed it is logged and skipped in the output. + """ + results = [] + for line in lines: + match = regex.search(line) + if not match: + _LOGGER.debug("Could not parse row: %s", line) + continue + results.append(match.groupdict()) + return results + + +Device = namedtuple('Device', ['mac', 'ip', 'name']) class AsusWrtDeviceScanner(DeviceScanner): @@ -95,28 +117,15 @@ def __init__(self, config): 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': - if not (self.ssh_key or self.password): - _LOGGER.error("No password or private key specified") - self.success_init = False - return - - self.connection = SshConnection(self.host, self.port, - self.username, - self.password, - self.ssh_key, - self.mode == "ap") + self.connection = SshConnection( + self.host, self.port, self.username, self.password, + self.ssh_key) else: - if not self.password: - _LOGGER.error("No password specified") - self.success_init = False - return - - self.connection = TelnetConnection(self.host, self.port, - self.username, - self.password, - self.mode == "ap") + self.connection = TelnetConnection( + self.host, self.port, self.username, self.password) self.last_results = {} @@ -127,16 +136,13 @@ def __init__(self, config): def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() - return [client['mac'] for client in self.last_results] + return list(self.last_results.keys()) def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - if not self.last_results: + if device not in self.last_results: return None - for client in self.last_results: - if client['mac'] == device: - return client['host'] - return None + return self.last_results[device].name def _update_info(self): """Ensure the information from the ASUSWRT router is up to date. @@ -151,72 +157,84 @@ def _update_info(self): if not data: return False - active_clients = [client for client in data.values() if - client['status'] == 'REACHABLE' or - client['status'] == 'DELAY' or - client['status'] == 'STALE' or - client['status'] == 'IN_ASSOCLIST'] - self.last_results = active_clients + self.last_results = data return True def get_asuswrt_data(self): - """Retrieve data from ASUSWRT and return parsed result.""" - result = self.connection.get_result() + """Retrieve data from ASUSWRT. - if not result: + Calls various commands on the router and returns the superset of all + responses. Some commands will not work on some routers. + """ + devices = {} + devices.update(self._get_wl()) + devices.update(self._get_arp()) + devices.update(self._get_neigh(devices)) + if not self.mode == 'ap': + devices.update(self._get_leases(devices)) + + ret_devices = {} + for key in devices: + if not self.require_ip or devices[key].ip is not None: + ret_devices[key] = devices[key] + return ret_devices + + def _get_wl(self): + lines = self.connection.run_command(_WL_CMD) + if not lines: return {} - + result = _parse_lines(lines, _WL_REGEX) devices = {} - if self.mode == 'ap': - for lease in result.leases: - match = _WL_REGEX.search(lease.decode('utf-8')) - - if not match: - _LOGGER.warning("Could not parse wl row: %s", lease) - continue + for device in result: + mac = device['mac'].upper() + devices[mac] = Device(mac, None, None) + return devices + def _get_leases(self, cur_devices): + lines = self.connection.run_command(_LEASES_CMD) + if not lines: + return {} + lines = [line for line in lines if not line.startswith('duid ')] + result = _parse_lines(lines, _LEASES_REGEX) + devices = {} + for device in result: + # For leases where the client doesn't set a hostname, ensure it + # is blank and not '*', which breaks entity_id down the line. + host = device['host'] + if host == '*': host = '' + mac = device['mac'].upper() + if mac in cur_devices: + devices[mac] = Device(mac, device['ip'], host) + return devices - devices[match.group('mac').upper()] = { - 'host': host, - 'status': 'IN_ASSOCLIST', - 'ip': '', - 'mac': match.group('mac').upper(), - } - - else: - for lease in result.leases: - if lease.startswith(b'duid '): - continue - match = _LEASES_REGEX.search(lease.decode('utf-8')) - - if not match: - _LOGGER.warning("Could not parse lease row: %s", lease) - continue - - # For leases where the client doesn't set a hostname, ensure it - # is blank and not '*', which breaks entity_id down the line. - host = match.group('host') - if host == '*': - host = '' - - devices[match.group('mac')] = { - 'host': host, - 'status': '', - 'ip': match.group('ip'), - 'mac': match.group('mac').upper(), - } - - for neighbor in result.neighbors: - match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8')) - if not match: - _LOGGER.warning("Could not parse neighbor row: %s", - neighbor) - continue - if match.group('mac') in devices: - devices[match.group('mac')]['status'] = ( - match.group('status')) + def _get_neigh(self, cur_devices): + lines = self.connection.run_command(_IP_NEIGH_CMD) + if not lines: + return {} + result = _parse_lines(lines, _IP_NEIGH_REGEX) + devices = {} + for device in result: + status = device['status'] + if status is None or status.upper() != 'REACHABLE': + continue + if device['mac'] is not None: + mac = device['mac'].upper() + old_device = cur_devices.get(mac) + old_ip = old_device.ip if old_device else None + devices[mac] = Device(mac, device.get('ip', old_ip), None) + return devices + def _get_arp(self): + lines = self.connection.run_command(_ARP_CMD) + if not lines: + return {} + result = _parse_lines(lines, _ARP_REGEX) + devices = {} + for device in result: + if device['mac'] is not None: + mac = device['mac'].upper() + devices[mac] = Device(mac, device['ip'], None) return devices @@ -230,7 +248,7 @@ def connected(self): return self._connected def connect(self): - """Mark currenct connection state as connected.""" + """Mark current connection state as connected.""" self._connected = True def disconnect(self): @@ -241,9 +259,9 @@ def disconnect(self): class SshConnection(_Connection): """Maintains an SSH connection to an ASUS-WRT router.""" - def __init__(self, host, port, username, password, ssh_key, ap): + def __init__(self, host, port, username, password, ssh_key): """Initialize the SSH connection properties.""" - super(SshConnection, self).__init__() + super().__init__() self._ssh = None self._host = host @@ -251,10 +269,9 @@ def __init__(self, host, port, username, password, ssh_key, ap): self._username = username self._password = password self._ssh_key = ssh_key - self._ap = ap - def get_result(self): - """Retrieve a single AsusWrtResult through an SSH connection. + def run_command(self, command): + """Run commands through an SSH connection. Connect to the SSH server if not currently connected, otherwise use the existing connection. @@ -264,29 +281,20 @@ def get_result(self): try: if not self.connected: self.connect() - if self._ap: - neighbors = [''] - self._ssh.sendline(_WL_CMD) - self._ssh.prompt() - leases_result = self._ssh.before.split(b'\n')[1:-1] - else: - self._ssh.sendline(_IP_NEIGH_CMD) - self._ssh.prompt() - neighbors = self._ssh.before.split(b'\n')[1:-1] - self._ssh.sendline(_LEASES_CMD) - self._ssh.prompt() - leases_result = self._ssh.before.split(b'\n')[1:-1] - return AsusWrtResult(neighbors, leases_result) + self._ssh.sendline(command) + self._ssh.prompt() + lines = self._ssh.before.split(b'\n')[1:-1] + return [line.decode('utf-8') for line in lines] except exceptions.EOF as err: - _LOGGER.error("Connection refused. SSH enabled?") + _LOGGER.error("Connection refused. %s", self._ssh.before) self.disconnect() return None except pxssh.ExceptionPxssh as err: - _LOGGER.error("Unexpected SSH error: %s", str(err)) + _LOGGER.error("Unexpected SSH error: %s", err) self.disconnect() return None except AssertionError as err: - _LOGGER.error("Connection to router unavailable: %s", str(err)) + _LOGGER.error("Connection to router unavailable: %s", err) self.disconnect() return None @@ -296,13 +304,13 @@ def connect(self): self._ssh = pxssh.pxssh() if self._ssh_key: - self._ssh.login(self._host, self._username, + self._ssh.login(self._host, self._username, quiet=False, ssh_key=self._ssh_key, port=self._port) else: - self._ssh.login(self._host, self._username, + self._ssh.login(self._host, self._username, quiet=False, password=self._password, port=self._port) - super(SshConnection, self).connect() + super().connect() def disconnect(self): \ # pylint: disable=broad-except @@ -314,26 +322,25 @@ def disconnect(self): \ finally: self._ssh = None - super(SshConnection, self).disconnect() + super().disconnect() class TelnetConnection(_Connection): """Maintains a Telnet connection to an ASUS-WRT router.""" - def __init__(self, host, port, username, password, ap): + def __init__(self, host, port, username, password): """Initialize the Telnet connection properties.""" - super(TelnetConnection, self).__init__() + super().__init__() self._telnet = None self._host = host self._port = port self._username = username self._password = password - self._ap = ap self._prompt_string = None - def get_result(self): - """Retrieve a single AsusWrtResult through a Telnet connection. + def run_command(self, command): + """Run a command through a Telnet connection. Connect to the Telnet server if not currently connected, otherwise use the existing connection. @@ -341,19 +348,10 @@ def get_result(self): try: if not self.connected: self.connect() - - self._telnet.write('{}\n'.format(_IP_NEIGH_CMD).encode('ascii')) - neighbors = (self._telnet.read_until(self._prompt_string). - split(b'\n')[1:-1]) - if self._ap: - self._telnet.write('{}\n'.format(_WL_CMD).encode('ascii')) - leases_result = (self._telnet.read_until(self._prompt_string). - split(b'\n')[1:-1]) - else: - self._telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii')) - leases_result = (self._telnet.read_until(self._prompt_string). - split(b'\n')[1:-1]) - return AsusWrtResult(neighbors, leases_result) + self._telnet.write('{}\n'.format(command).encode('ascii')) + data = (self._telnet.read_until(self._prompt_string). + split(b'\n')[1:-1]) + return [line.decode('utf-8') for line in data] except EOFError: _LOGGER.error("Unexpected response from router") self.disconnect() @@ -380,7 +378,7 @@ def connect(self): self._telnet.write((self._password + '\n').encode('ascii')) self._prompt_string = self._telnet.read_until(b'#').split(b'\n')[-1] - super(TelnetConnection, self).connect() + super().connect() def disconnect(self): \ # pylint: disable=broad-except @@ -390,4 +388,4 @@ def disconnect(self): \ except Exception: pass - super(TelnetConnection, self).disconnect() + super().disconnect() diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 6ae038fd41c28..607f236f92052 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -14,8 +14,8 @@ import voluptuous as vol from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, ATTR_ATTRIBUTES, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_MAC, - ATTR_GPS, ATTR_GPS_ACCURACY) + ATTR_ATTRIBUTES, ATTR_DEV_ID, ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_HOST_NAME, + ATTR_MAC, PLATFORM_SCHEMA) from homeassistant.components.http import HomeAssistantView from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback @@ -23,36 +23,33 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.6.2'] -DEPENDENCIES = ['http'] +REQUIREMENTS = ['aioautomatic==0.6.5'] _LOGGER = logging.getLogger(__name__) +ATTR_FUEL_LEVEL = 'fuel_level' +AUTOMATIC_CONFIG_FILE = '.automatic/session-{}.json' + CONF_CLIENT_ID = 'client_id' -CONF_SECRET = 'secret' -CONF_DEVICES = 'devices' CONF_CURRENT_LOCATION = 'current_location' +CONF_DEVICES = 'devices' +CONF_SECRET = 'secret' +DATA_CONFIGURING = 'automatic_configurator_clients' +DATA_REFRESH_TOKEN = 'refresh_token' +DEFAULT_SCOPE = ['location', 'trip', 'vehicle:events', 'vehicle:profile'] DEFAULT_TIMEOUT = 5 - -DEFAULT_SCOPE = ['location', 'vehicle:profile', 'trip'] -FULL_SCOPE = DEFAULT_SCOPE + ['current_location'] - -ATTR_FUEL_LEVEL = 'fuel_level' +DEPENDENCIES = ['http'] EVENT_AUTOMATIC_UPDATE = 'automatic_update' -AUTOMATIC_CONFIG_FILE = '.automatic/session-{}.json' - -DATA_CONFIGURING = 'automatic_configurator_clients' -DATA_REFRESH_TOKEN = 'refresh_token' +FULL_SCOPE = DEFAULT_SCOPE + ['current_location'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_SECRET): cv.string, vol.Optional(CONF_CURRENT_LOCATION, default=False): cv.boolean, - vol.Optional(CONF_DEVICES, default=None): vol.All( - cv.ensure_list, [cv.string]) + vol.Optional(CONF_DEVICES): vol.All(cv.ensure_list, [cv.string]), }) @@ -111,7 +108,7 @@ def initialize_data(session): _write_refresh_token_to_file, hass, filename, session.refresh_token) data = AutomaticData( - hass, client, session, config[CONF_DEVICES], async_see) + hass, client, session, config.get(CONF_DEVICES), async_see) # Load the initial vehicle data vehicles = yield from session.get_vehicles() @@ -142,7 +139,7 @@ def initialize_data(session): @asyncio.coroutine def initialize_callback(code, state): - """Callback after OAuth2 response is returned.""" + """Call after OAuth2 response is returned.""" try: session = yield from client.create_session_from_oauth_code( code, state) @@ -179,14 +176,13 @@ def get(self, request): # pylint: disable=no-self-use _LOGGER.error( "Error authorizing Automatic: %s", params['error']) return response - else: - _LOGGER.error( - "Error authorizing Automatic. Invalid response returned.") - return response + _LOGGER.error( + "Error authorizing Automatic. Invalid response returned") + return response if DATA_CONFIGURING not in hass.data or \ params['state'] not in hass.data[DATA_CONFIGURING]: - _LOGGER.error("Automatic configuration request not found.") + _LOGGER.error("Automatic configuration request not found") return response code = params['code'] @@ -220,16 +216,15 @@ def __init__(self, hass, client, session, devices, async_see): @asyncio.coroutine def handle_event(self, name, event): - """Coroutine to update state for a realtime event.""" + """Coroutine to update state for a real time event.""" import aioautomatic - # Fire a hass event self.hass.bus.async_fire(EVENT_AUTOMATIC_UPDATE, event.data) if event.vehicle.id not in self.vehicle_info: # If vehicle hasn't been seen yet, request the detailed # info for this vehicle. - _LOGGER.info("New vehicle found.") + _LOGGER.info("New vehicle found") try: vehicle = yield from event.get_vehicle() except aioautomatic.exceptions.AutomaticError as err: @@ -240,7 +235,7 @@ def handle_event(self, name, event): if event.created_at < self.vehicle_seen[event.vehicle.id]: # Skip events received out of order _LOGGER.debug("Skipping out of order event. Event Created %s. " - "Last seen event: %s.", event.created_at, + "Last seen event: %s", event.created_at, self.vehicle_seen[event.vehicle.id]) return self.vehicle_seen[event.vehicle.id] = event.created_at @@ -270,13 +265,13 @@ def ws_connect(self, now=None): self.ws_close_requested = False if self.ws_reconnect_handle is not None: - _LOGGER.debug("Retrying websocket connection.") + _LOGGER.debug("Retrying websocket connection") try: ws_loop_future = yield from self.client.ws_connect() except aioautomatic.exceptions.UnauthorizedClientError: _LOGGER.error("Client unauthorized for websocket connection. " "Ensure Websocket is selected in the Automatic " - "developer application event delivery preferences.") + "developer application event delivery preferences") return except aioautomatic.exceptions.AutomaticError as err: if self.ws_reconnect_handle is None: @@ -290,14 +285,14 @@ def ws_connect(self, now=None): self.ws_reconnect_handle() self.ws_reconnect_handle = None - _LOGGER.info("Websocket connected.") + _LOGGER.info("Websocket connected") try: yield from ws_loop_future except aioautomatic.exceptions.AutomaticError as err: _LOGGER.error(str(err)) - _LOGGER.info("Websocket closed.") + _LOGGER.info("Websocket closed") # If websocket was close was not requested, attempt to reconnect if not self.ws_close_requested: diff --git a/homeassistant/components/device_tracker/bbox.py b/homeassistant/components/device_tracker/bbox.py index 23a94d093e246..6d870364dcb64 100644 --- a/homeassistant/components/device_tracker/bbox.py +++ b/homeassistant/components/device_tracker/bbox.py @@ -45,10 +45,10 @@ def scan_devices(self): return [device.mac for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [device.name for device in self.last_results if - device.mac == mac] + filter_named = [result.name for result in self.last_results if + result.mac == device] if filter_named: return filter_named[0] diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index 22713cdc18efc..d9cda24b69930 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -10,7 +10,7 @@ from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.components.device_tracker import ( YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, - PLATFORM_SCHEMA, load_config + PLATFORM_SCHEMA, load_config, SOURCE_TYPE_BLUETOOTH_LE ) import homeassistant.util.dt as dt_util import homeassistant.helpers.config_validation as cv @@ -54,7 +54,8 @@ def see_device(address, name, new_device=False): new_devices[address] = 1 return - see(mac=BLE_PREFIX + address, host_name=name.strip("\x00")) + see(mac=BLE_PREFIX + address, host_name=name.strip("\x00"), + source_type=SOURCE_TYPE_BLUETOOTH_LE) def discover_ble_devices(): """Discover Bluetooth LE devices.""" @@ -101,7 +102,7 @@ def update_ble(now): """Lookup Bluetooth LE devices and update status.""" devs = discover_ble_devices() for mac in devs_to_track: - _LOGGER.debug("Checking " + mac) + _LOGGER.debug("Checking %s", mac) result = mac in devs if not result: # Could not lookup device name diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py index 9e0957e363f20..2ca519d225c4a 100644 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_tracker.py @@ -12,17 +12,20 @@ from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.components.device_tracker import ( YAML_DEVICES, CONF_TRACK_NEW, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, - load_config, PLATFORM_SCHEMA, DEFAULT_TRACK_NEW) + load_config, PLATFORM_SCHEMA, DEFAULT_TRACK_NEW, SOURCE_TYPE_BLUETOOTH) 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,17 +33,22 @@ 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]) + 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.""" result = bluetooth.discover_devices( duration=8, lookup_names=True, flush_cache=True, lookup_class=False) - _LOGGER.debug("Bluetooth devices discovered = " + str(len(result))) + _LOGGER.debug("Bluetooth devices discovered = %d", len(result)) return result yaml_path = hass.config.path(YAML_DEVICES) @@ -63,27 +71,32 @@ def discover_devices(): 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 new file mode 100644 index 0000000000000..f36afc622ee1b --- /dev/null +++ b/homeassistant/components/device_tracker/bmw_connected_drive.py @@ -0,0 +1,58 @@ +"""Device tracker for BMW Connected Drive vehicles. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.bmw_connected_drive/ +""" +import logging + +from homeassistant.components.bmw_connected_drive import DOMAIN \ + as BMW_DOMAIN +from homeassistant.util import slugify + +DEPENDENCIES = ['bmw_connected_drive'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the BMW tracker.""" + accounts = hass.data[BMW_DOMAIN] + _LOGGER.debug('Found BMW accounts: %s', + ', '.join([a.name for a in accounts])) + for account in accounts: + for vehicle in account.account.vehicles: + tracker = BMWDeviceTracker(see, vehicle) + account.add_update_listener(tracker.update) + tracker.update() + return True + + +class BMWDeviceTracker(object): + """BMW Connected Drive device tracker.""" + + def __init__(self, see, vehicle): + """Initialize the Tracker.""" + self._see = see + self.vehicle = vehicle + + def update(self) -> None: + """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 = { + 'vin': self.vehicle.vin, + } + self._see( + 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/fritz.py b/homeassistant/components/device_tracker/fritz.py index 5210329179ff0..8c9d1988a7132 100644 --- a/homeassistant/components/device_tracker/fritz.py +++ b/homeassistant/components/device_tracker/fritz.py @@ -13,7 +13,7 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -REQUIREMENTS = ['fritzconnection==0.6.3'] +REQUIREMENTS = ['fritzconnection==0.6.5'] _LOGGER = logging.getLogger(__name__) @@ -75,9 +75,9 @@ def scan_devices(self): active_hosts.append(known_host['mac']) return active_hosts - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if is not known.""" - ret = self.fritz_box.get_specific_host_entry(mac).get( + ret = self.fritz_box.get_specific_host_entry(device).get( 'NewHostName' ) if ret == {}: diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py old mode 100755 new mode 100644 index d4e576bad7462..adb5c6f6d28df --- a/homeassistant/components/device_tracker/geofency.py +++ b/homeassistant/components/device_tracker/geofency.py @@ -21,6 +21,9 @@ DEPENDENCIES = ['http'] +ATTR_CURRENT_LATITUDE = 'currentLatitude' +ATTR_CURRENT_LONGITUDE = 'currentLongitude' + BEACON_DEV_PREFIX = 'beacon' CONF_MOBILE_BEACONS = 'mobile_beacons' @@ -72,6 +75,9 @@ def post(self, request): location_name = data['name'] else: location_name = STATE_NOT_HOME + if ATTR_CURRENT_LATITUDE in data: + data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE] + data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE] return (yield from self._set_location(hass, data, location_name)) @@ -96,8 +102,12 @@ def _validate_data(data): data['device'] = slugify(data['device']) data['name'] = slugify(data['name']) - data[ATTR_LATITUDE] = float(data[ATTR_LATITUDE]) - data[ATTR_LONGITUDE] = float(data[ATTR_LONGITUDE]) + gps_attributes = [ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_CURRENT_LATITUDE, ATTR_CURRENT_LONGITUDE] + + for attribute in gps_attributes: + if attribute in data: + data[attribute] = float(data[attribute]) return data @@ -110,8 +120,7 @@ def _device_name(data): """Return name of device tracker.""" if 'beaconUUID' in data: return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) - else: - return data['device'] + return data['device'] @asyncio.coroutine def _set_location(self, hass, data, location_name): diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py new file mode 100644 index 0000000000000..1d0058ed22984 --- /dev/null +++ b/homeassistant/components/device_tracker/google_maps.py @@ -0,0 +1,84 @@ +""" +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/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, SOURCE_TYPE_GPS) +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['locationsharinglib==1.2.2'] + +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(): + dev_id = 'google_maps_{0}'.format(slugify(person.id)) + + attrs = { + 'id': person.id, + 'nickname': person.nickname, + 'full_name': person.full_name, + 'last_seen': person.datetime, + 'address': person.address + } + 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 b88245ac9a5b1..68ea9ac88ae81 100644 --- a/homeassistant/components/device_tracker/gpslogger.py +++ b/homeassistant/components/device_tracker/gpslogger.py @@ -4,24 +4,38 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.gpslogger/ """ -import asyncio -from functools import partial import logging - -from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY -from homeassistant.components.http import HomeAssistantView +from hmac import compare_digest + +from aiohttp.web import Request, HTTPUnauthorized # NOQA +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_PASSWORD, HTTP_UNPROCESSABLE_ENTITY +) +from homeassistant.components.http import ( + CONF_API_PASSWORD, HomeAssistantView +) # pylint: disable=unused-import from homeassistant.components.device_tracker import ( # NOQA - DOMAIN, PLATFORM_SCHEMA) + DOMAIN, PLATFORM_SCHEMA +) +from homeassistant.helpers.typing import HomeAssistantType, ConfigType _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['http'] +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_PASSWORD): cv.string, +}) + -def setup_scanner(hass, config, 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(see)) + hass.http.register_view(GPSLoggerView(async_see, config)) return True @@ -32,26 +46,35 @@ class GPSLoggerView(HomeAssistantView): url = '/api/gpslogger' name = 'api:gpslogger' - def __init__(self, see): + def __init__(self, async_see, config): """Initialize GPSLogger url endpoints.""" - self.see = see + self.async_see = async_see + self._password = config.get(CONF_PASSWORD) + # this component does not require external authentication if + # password is set + self.requires_auth = self._password is None - @asyncio.coroutine - def get(self, request): + async def get(self, request: Request): """Handle for GPSLogger message received as GET.""" - res = yield from self._handle(request.app['hass'], request.query) - return res + hass = request.app['hass'] + data = request.query + + if self._password is not None: + authenticated = CONF_API_PASSWORD in data and compare_digest( + self._password, + data[CONF_API_PASSWORD] + ) + if not authenticated: + raise HTTPUnauthorized() - @asyncio.coroutine - def _handle(self, hass, data): - """Handle GPSLogger requests.""" if 'latitude' not in data or 'longitude' not in data: return ('Latitude and longitude not specified.', HTTP_UNPROCESSABLE_ENTITY) if 'device' not in data: _LOGGER.error("Device id not specified") - return ('Device id not specified.', HTTP_UNPROCESSABLE_ENTITY) + return ('Device id not specified.', + HTTP_UNPROCESSABLE_ENTITY) device = data['device'].replace('-', '') gps_location = (data['latitude'], data['longitude']) @@ -75,10 +98,11 @@ def _handle(self, hass, data): if 'activity' in data: attrs['activity'] = data['activity'] - yield from hass.async_add_job( - partial(self.see, dev_id=device, - gps=gps_location, battery=battery, - gps_accuracy=accuracy, - attributes=attrs)) + hass.async_add_job(self.async_see( + dev_id=device, + gps=gps_location, battery=battery, + gps_accuracy=accuracy, + attributes=attrs + )) return 'Setting location for {}'.format(device) diff --git a/homeassistant/components/device_tracker/hitron_coda.py b/homeassistant/components/device_tracker/hitron_coda.py new file mode 100644 index 0000000000000..c9cd30cdb259a --- /dev/null +++ b/homeassistant/components/device_tracker/hitron_coda.py @@ -0,0 +1,146 @@ +""" +Support for the Hitron CODA-4582U, provided by Rogers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.hitron_coda/ +""" +import logging +from collections import namedtuple + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_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.Optional(CONF_TYPE, default=DEFAULT_TYPE): cv.string, +}) + + +def get_scanner(_hass, config): + """Validate the configuration and return a Nmap scanner.""" + scanner = HitronCODADeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +Device = namedtuple('Device', ['mac', 'name']) + + +class HitronCODADeviceScanner(DeviceScanner): + """This class scans for devices using the CODA's web interface.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.last_results = [] + host = config[CONF_HOST] + self._url = 'http://{}/data/getConnectInfo.asp'.format(host) + self._loginurl = 'http://{}/goform/login'.format(host) + + 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() + _LOGGER.info("Scanner initialized") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device.mac for device in self.last_results] + + def get_device_name(self, device): + """Return the name of the device with the given MAC address.""" + name = next(( + result.name for result in self.last_results + if result.mac == device), None) + return name + + def _login(self): + """Log in to the router. This is required for subsequent api calls.""" + _LOGGER.info("Logging in to CODA...") + + try: + data = [ + ('user', self._username), + (self._type, self._password), + ] + res = requests.post(self._loginurl, data=data, timeout=10) + except requests.exceptions.Timeout: + _LOGGER.error( + "Connection to the router timed out at URL %s", self._url) + return False + if res.status_code != 200: + _LOGGER.error( + "Connection failed with http code %s", res.status_code) + return False + try: + self._userid = res.cookies['userid'] + return True + except KeyError: + _LOGGER.error("Failed to log in to router") + return False + + def _update_info(self): + """Get ARP from router.""" + _LOGGER.info("Fetching...") + + if self._userid is None: + if not self._login(): + _LOGGER.error("Could not obtain a user ID from the router") + return False + last_results = [] + + # doing a request + try: + res = requests.get(self._url, timeout=10, cookies={ + 'userid': self._userid + }) + except requests.exceptions.Timeout: + _LOGGER.error( + "Connection to the router timed out at URL %s", self._url) + return False + if res.status_code != 200: + _LOGGER.error( + "Connection failed with http code %s", res.status_code) + return False + try: + result = res.json() + except ValueError: + # If json decoder could not parse the response + _LOGGER.error("Failed to parse response from router") + return False + + # parsing response + for info in result: + mac = info['macAddr'] + name = info['hostName'] + # No address = no item :) + if mac is None: + continue + + last_results.append(Device(mac.upper(), name)) + + self.last_results = last_results + + _LOGGER.info("Request successful") + return True diff --git a/homeassistant/components/device_tracker/huawei_router.py b/homeassistant/components/device_tracker/huawei_router.py index b78683696cf45..775075b8a4aae 100644 --- a/homeassistant/components/device_tracker/huawei_router.py +++ b/homeassistant/components/device_tracker/huawei_router.py @@ -2,7 +2,7 @@ Support for HUAWEI routers. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.huawei/ +https://home-assistant.io/components/device_tracker.huawei_router/ """ import base64 import logging @@ -86,6 +86,7 @@ def _update_info(self): active_clients = [client for client in data if client.state] self.last_results = active_clients + # pylint: disable=logging-not-lazy _LOGGER.debug("Active clients: " + "\n" .join((client.mac + " " + client.name) for client in active_clients)) @@ -119,7 +120,7 @@ def _get_devices_response(self): cnt = requests.post('http://{}/asp/GetRandCount.asp'.format(self.host)) cnt_str = str(cnt.content, cnt.apparent_encoding, errors='replace') - _LOGGER.debug("Loggin in") + _LOGGER.debug("Logging in") cookie = requests.post('http://{}/login.cgi'.format(self.host), data=[('UserName', self.username), ('PassWord', self.password), diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index e670287dd879d..8ea81e88440f7 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -13,7 +13,7 @@ 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 @@ 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 @@ 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 lost_iphone(call): 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 update_icloud(call): 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 reset_account_icloud(call): 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 setinterval(call): 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 @@ def __init__(self, hass, username, password, name, see): 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 @@ -189,10 +201,12 @@ def reset_account_icloud(self): for device in self.api.devices: status = device.status(DEVICESTATUSSET) devicename = slugify(status['name'].replace(' ', '', 99)) - if devicename not in self.devices: - self.devices[devicename] = device - self._intervals[devicename] = 1 - self._overridestates[devicename] = None + if devicename in self.devices: + _LOGGER.error('Multiple devices with name: %s', devicename) + continue + self.devices[devicename] = device + self._intervals[devicename] = 1 + self._overridestates[devicename] = None except PyiCloudNoDevicesException: _LOGGER.error('No iCloud Devices found!') @@ -248,7 +262,7 @@ def icloud_verification_callback(self, callback_data): self._trusted_device, self._verification_code): raise PyiCloudException('Unknown failure') except PyiCloudException as error: - # Reset to the inital 2FA state to allow the user to retry + # Reset to the initial 2FA state to allow the user to retry _LOGGER.error("Failed to verify verification code: %s", error) self._trusted_device = None self._verification_code = None @@ -319,14 +333,6 @@ def keep_alive(self, now): def determine_interval(self, devicename, latitude, longitude, battery): """Calculate new interval.""" - distancefromhome = None - zone_state = self.hass.states.get('zone.home') - zone_state_lat = zone_state.attributes['latitude'] - zone_state_long = zone_state.attributes['longitude'] - distancefromhome = distance( - latitude, longitude, zone_state_lat, zone_state_long) - distancefromhome = round(distancefromhome / 1000, 1) - currentzone = active_zone(self.hass, latitude, longitude) if ((currentzone is not None and @@ -335,22 +341,47 @@ def determine_interval(self, devicename, latitude, longitude, battery): self._overridestates.get(devicename) == 'away')): return + zones = (self.hass.states.get(entity_id) for entity_id + in sorted(self.hass.states.entity_ids('zone'))) + + distances = [] + for zone_state in zones: + zone_state_lat = zone_state.attributes['latitude'] + zone_state_long = zone_state.attributes['longitude'] + zone_distance = distance( + latitude, longitude, zone_state_lat, zone_state_long) + distances.append(round(zone_distance / 1000, 1)) + + if distances: + mindistance = min(distances) + else: + mindistance = None + self._overridestates[devicename] = None if currentzone is not None: - self._intervals[devicename] = 30 + self._intervals[devicename] = self._max_interval return - if distancefromhome is None: + if mindistance is None: return - if distancefromhome > 25: - self._intervals[devicename] = round(distancefromhome / 2, 0) - elif distancefromhome > 10: - self._intervals[devicename] = 5 - else: - self._intervals[devicename] = 1 - if battery is not None and battery <= 33 and distancefromhome > 3: - self._intervals[devicename] = self._intervals[devicename] * 2 + + # Calculate out how long it would take for the device to drive to the + # nearest zone at 120 km/h: + interval = round(mindistance / 2, 0) + + # Never poll more than once per minute + interval = max(interval, 1) + + if interval > 180: + # Three hour drive? This is far enough that they might be flying + interval = 30 + + if battery is not None and battery <= 33 and mindistance > 3: + # Low battery - let's check half as often + interval = interval * 2 + + self._intervals[devicename] = interval def update_device(self, devicename): """Update the device_tracker entity.""" @@ -383,22 +414,24 @@ def update_device(self, devicename): 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") @@ -414,7 +447,7 @@ def lost_iphone(self, devicename): 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: @@ -423,13 +456,13 @@ def update_icloud(self, devicename=None): 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/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py new file mode 100644 index 0000000000000..36dc1182a9294 --- /dev/null +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -0,0 +1,121 @@ +""" +Support for Zyxel Keenetic NDMS2 based routers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.keenetic_ndms2/ +""" +import logging +from collections import namedtuple + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME +) + +_LOGGER = logging.getLogger(__name__) + +# Interface name to track devices for. Most likely one will not need to +# change it from default 'Home'. This is needed not to track Guest WI-FI- +# clients and router itself +CONF_INTERFACE = 'interface' + +DEFAULT_INTERFACE = 'Home' + + +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_INTERFACE, default=DEFAULT_INTERFACE): cv.string, +}) + + +def get_scanner(_hass, config): + """Validate the configuration and return a Nmap scanner.""" + scanner = KeeneticNDMS2DeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +Device = namedtuple('Device', ['mac', 'name']) + + +class KeeneticNDMS2DeviceScanner(DeviceScanner): + """This class scans for devices using keenetic NDMS2 web interface.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.last_results = [] + + self._url = 'http://%s/rci/show/ip/arp' % config[CONF_HOST] + self._interface = config[CONF_INTERFACE] + + self._username = config.get(CONF_USERNAME) + self._password = config.get(CONF_PASSWORD) + + self.success_init = self._update_info() + _LOGGER.info("Scanner initialized") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device.mac for device in self.last_results] + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + filter_named = [result.name for result in self.last_results + if result.mac == device] + + if filter_named: + return filter_named[0] + return None + + def _update_info(self): + """Get ARP from keenetic router.""" + _LOGGER.info("Fetching...") + + last_results = [] + + # doing a request + try: + from requests.auth import HTTPDigestAuth + res = requests.get(self._url, timeout=10, auth=HTTPDigestAuth( + self._username, self._password + )) + except requests.exceptions.Timeout: + _LOGGER.error( + "Connection to the router timed out at URL %s", self._url) + return False + if res.status_code != 200: + _LOGGER.error( + "Connection failed with http code %s", res.status_code) + return False + try: + result = res.json() + except ValueError: + # If json decoder could not parse the response + _LOGGER.error("Failed to parse response from router") + return False + + # parsing response + for info in result: + if info.get('interface') != self._interface: + continue + mac = info.get('mac') + name = info.get('name') + # No address = no item :) + if mac is None: + continue + + last_results.append(Device(mac.upper(), name)) + + self.last_results = last_results + + _LOGGER.info("Request successful") + return True diff --git a/homeassistant/components/device_tracker/linksys_ap.py b/homeassistant/components/device_tracker/linksys_ap.py index 196235f32f4f6..8837b628b3265 100644 --- a/homeassistant/components/device_tracker/linksys_ap.py +++ b/homeassistant/components/device_tracker/linksys_ap.py @@ -11,7 +11,8 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL) @@ -38,7 +39,7 @@ def get_scanner(hass, config): return None -class LinksysAPDeviceScanner(object): +class LinksysAPDeviceScanner(DeviceScanner): """This class queries a Linksys Access Point.""" def __init__(self, config): @@ -61,7 +62,7 @@ def scan_devices(self): return self.last_results # pylint: disable=no-self-use - def get_device_name(self, mac): + def get_device_name(self, device): """ Return the name (if known) of the device. diff --git a/homeassistant/components/device_tracker/linksys_smart.py b/homeassistant/components/device_tracker/linksys_smart.py index 4bcbb600b8bdb..c92f940f52637 100644 --- a/homeassistant/components/device_tracker/linksys_smart.py +++ b/homeassistant/components/device_tracker/linksys_smart.py @@ -45,9 +45,9 @@ def scan_devices(self): return self.last_results.keys() - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name (if known) of the device.""" - return self.last_results.get(mac) + return self.last_results.get(device) def _update_info(self): """Check for connected devices.""" diff --git a/homeassistant/components/device_tracker/meraki.py b/homeassistant/components/device_tracker/meraki.py new file mode 100644 index 0000000000000..9bbc6bf9ffed1 --- /dev/null +++ b/homeassistant/components/device_tracker/meraki.py @@ -0,0 +1,135 @@ +""" +Support for the Meraki CMX location service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.meraki/ + +""" +import asyncio +import logging +import json + +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_UNPROCESSABLE_ENTITY) +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, SOURCE_TYPE_ROUTER) + +CONF_VALIDATOR = 'validator' +CONF_SECRET = 'secret' +DEPENDENCIES = ['http'] +URL = '/api/meraki' +VERSION = '2.0' + + +_LOGGER = logging.getLogger(__name__) + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_VALIDATOR): cv.string, + vol.Required(CONF_SECRET): cv.string +}) + + +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up an endpoint for the Meraki tracker.""" + hass.http.register_view( + MerakiView(config, async_see)) + + return True + + +class MerakiView(HomeAssistantView): + """View to handle Meraki requests.""" + + url = URL + name = 'api:meraki' + + def __init__(self, config, async_see): + """Initialize Meraki URL endpoints.""" + self.async_see = async_see + self.validator = config[CONF_VALIDATOR] + self.secret = config[CONF_SECRET] + + @asyncio.coroutine + def get(self, request): + """Meraki message received as GET.""" + return self.validator + + @asyncio.coroutine + def post(self, request): + """Meraki CMX message received.""" + try: + data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + _LOGGER.debug("Meraki Data from Post: %s", json.dumps(data)) + if not data.get('secret', False): + _LOGGER.error("secret invalid") + return self.json_message('No secret', HTTP_UNPROCESSABLE_ENTITY) + if data['secret'] != self.secret: + _LOGGER.error("Invalid Secret received from Meraki") + return self.json_message('Invalid secret', + HTTP_UNPROCESSABLE_ENTITY) + elif data['version'] != VERSION: + _LOGGER.error("Invalid API version: %s", data['version']) + return self.json_message('Invalid version', + HTTP_UNPROCESSABLE_ENTITY) + else: + _LOGGER.debug('Valid Secret') + if data['type'] not in ('DevicesSeen', 'BluetoothDevicesSeen'): + _LOGGER.error("Unknown Device %s", data['type']) + return self.json_message('Invalid device type', + HTTP_UNPROCESSABLE_ENTITY) + _LOGGER.debug("Processing %s", data['type']) + if not data["data"]["observations"]: + _LOGGER.debug("No observations found") + return + self._handle(request.app['hass'], data) + + @callback + def _handle(self, hass, data): + for i in data["data"]["observations"]: + data["data"]["secret"] = "hidden" + + lat = i["location"]["lat"] + lng = i["location"]["lng"] + try: + accuracy = int(float(i["location"]["unc"])) + except ValueError: + accuracy = 0 + + mac = i["clientMac"] + _LOGGER.debug("clientMac: %s", mac) + + if lat == "NaN" or lng == "NaN": + _LOGGER.debug( + "No coordinates received, skipping location for: %s", mac) + gps_location = None + accuracy = None + else: + gps_location = (lat, lng) + + attrs = {} + if i.get('os', False): + attrs['os'] = i['os'] + if i.get('manufacturer', False): + attrs['manufacturer'] = i['manufacturer'] + if i.get('ipv4', False): + attrs['ipv4'] = i['ipv4'] + if i.get('ipv6', False): + attrs['ipv6'] = i['ipv6'] + if i.get('seenTime', False): + attrs['seenTime'] = i['seenTime'] + if i.get('ssid', False): + attrs['ssid'] = i['ssid'] + hass.async_add_job(self.async_see( + gps=gps_location, + mac=mac, + source_type=SOURCE_TYPE_ROUTER, + gps_accuracy=accuracy, + attributes=attrs + )) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 4e43b6ac10ddc..a6a67749f764e 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) -REQUIREMENTS = ['librouteros==1.0.2'] +REQUIREMENTS = ['librouteros==1.0.5'] MTK_DEFAULT_API_PORT = '8728' @@ -73,19 +73,51 @@ def connect_to_device(self): self.host, self.username, self.password, - port=int(self.port) + port=int(self.port), + encoding='utf-8' ) - routerboard_info = self.client(cmd='/system/routerboard/getall') + try: + routerboard_info = self.client( + cmd='/system/routerboard/getall') + except (librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ConnectionError): + routerboard_info = None + raise if routerboard_info: _LOGGER.info("Connected to Mikrotik %s with IP %s", routerboard_info[0].get('model', 'Router'), self.host) + self.connected = True - self.wireless_exist = self.client( - cmd='/interface/wireless/getall' - ) + + try: + self.capsman_exist = self.client( + cmd='/caps-man/interface/getall' + ) + except (librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ConnectionError): + self.capsman_exist = False + + if not self.capsman_exist: + _LOGGER.info( + 'Mikrotik %s: Not a CAPSman controller. Trying ' + 'local interfaces ', + self.host + ) + + try: + self.wireless_exist = self.client( + cmd='/interface/wireless/getall' + ) + except (librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ConnectionError): + self.wireless_exist = False + if not self.wireless_exist: _LOGGER.info( 'Mikrotik %s: Wireless adapters not found. Try to ' @@ -95,6 +127,7 @@ def connect_to_device(self): ) except (librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, librouteros.exceptions.ConnectionError) as api_error: _LOGGER.error("Connection error: %s", api_error) @@ -105,13 +138,15 @@ def scan_devices(self): self._update_info() return [device for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - return self.last_results.get(mac) + return self.last_results.get(device) def _update_info(self): """Retrieve latest information from the Mikrotik box.""" - if self.wireless_exist: + if self.capsman_exist: + devices_tracker = 'capsman' + elif self.wireless_exist: devices_tracker = 'wireless' else: devices_tracker = 'ip' @@ -123,7 +158,11 @@ def _update_info(self): ) device_names = self.client(cmd='/ip/dhcp-server/lease/getall') - if self.wireless_exist: + if devices_tracker == 'capsman': + devices = self.client( + cmd='/caps-man/registration-table/getall' + ) + elif devices_tracker == 'wireless': devices = self.client( cmd='/interface/wireless/registration-table/getall' ) @@ -137,7 +176,7 @@ def _update_info(self): 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/mqtt.py b/homeassistant/components/device_tracker/mqtt.py index aab5b43acea2f..2e2d9b10d9859 100644 --- a/homeassistant/components/device_tracker/mqtt.py +++ b/homeassistant/components/device_tracker/mqtt.py @@ -31,17 +31,14 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): devices = config[CONF_DEVICES] qos = config[CONF_QOS] - dev_id_lookup = {} - - @callback - def async_tracker_message_received(topic, payload, qos): - """Handle received MQTT message.""" - hass.async_add_job( - async_see(dev_id=dev_id_lookup[topic], location_name=payload)) - for dev_id, topic in devices.items(): - dev_id_lookup[topic] = dev_id + @callback + def async_message_received(topic, payload, qos, dev_id=dev_id): + """Handle received MQTT message.""" + hass.async_add_job( + async_see(dev_id=dev_id, location_name=payload)) + yield from mqtt.async_subscribe( - hass, topic, async_tracker_message_received, qos) + hass, topic, async_message_received, qos) return True diff --git a/homeassistant/components/device_tracker/mqtt_json.py b/homeassistant/components/device_tracker/mqtt_json.py index 0ef4f1835b64f..9a5532fc9f440 100644 --- a/homeassistant/components/device_tracker/mqtt_json.py +++ b/homeassistant/components/device_tracker/mqtt_json.py @@ -26,8 +26,8 @@ GPS_JSON_PAYLOAD_SCHEMA = vol.Schema({ vol.Required(ATTR_LATITUDE): vol.Coerce(float), vol.Required(ATTR_LONGITUDE): vol.Coerce(float), - vol.Optional(ATTR_GPS_ACCURACY, default=None): vol.Coerce(int), - vol.Optional(ATTR_BATTERY_LEVEL, default=None): vol.Coerce(str), + vol.Optional(ATTR_GPS_ACCURACY): vol.Coerce(int), + vol.Optional(ATTR_BATTERY_LEVEL): vol.Coerce(str), }, extra=vol.ALLOW_EXTRA) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(mqtt.SCHEMA_BASE).extend({ @@ -41,32 +41,26 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): devices = config[CONF_DEVICES] qos = config[CONF_QOS] - dev_id_lookup = {} - - @callback - def async_tracker_message_received(topic, payload, qos): - """Handle received MQTT message.""" - dev_id = dev_id_lookup[topic] - - try: - data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(payload)) - except vol.MultipleInvalid: - _LOGGER.error("Skipping update for following data " - "because of missing or malformatted data: %s", - payload) - return - except ValueError: - _LOGGER.error("Error parsing JSON payload: %s", payload) - return - - kwargs = _parse_see_args(dev_id, data) - hass.async_add_job( - async_see(**kwargs)) - for dev_id, topic in devices.items(): - dev_id_lookup[topic] = dev_id + @callback + def async_message_received(topic, payload, qos, dev_id=dev_id): + """Handle received MQTT message.""" + try: + data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(payload)) + except vol.MultipleInvalid: + _LOGGER.error("Skipping update for following data " + "because of missing or malformatted data: %s", + payload) + return + except ValueError: + _LOGGER.error("Error parsing JSON payload: %s", payload) + return + + kwargs = _parse_see_args(dev_id, data) + hass.async_add_job(async_see(**kwargs)) + yield from mqtt.async_subscribe( - hass, topic, async_tracker_message_received, qos) + hass, topic, async_message_received, qos) return True diff --git a/homeassistant/components/device_tracker/mysensors.py b/homeassistant/components/device_tracker/mysensors.py index f68eb361ca097..b0d29bf056675 100644 --- a/homeassistant/components/device_tracker/mysensors.py +++ b/homeassistant/components/device_tracker/mysensors.py @@ -6,15 +6,15 @@ """ 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 d2b8bc274ca3a..0e48e3072b208 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -12,21 +12,27 @@ 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 @@ def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() - return (device.mac for device in self.last_results) - - def get_device_name(self, mac): - """Return the name of the given device or None if we don't know.""" - try: - return next(device.name for device in self.last_results - if device.mac == mac) - except StopIteration: - return None + 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 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 @@ def _update_info(self): _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 e9d70142ad11e..3c090e8cd3b9f 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -33,7 +33,7 @@ vol.Required(CONF_HOSTS): cv.ensure_list, vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int, vol.Optional(CONF_EXCLUDE, default=[]): - vol.All(cv.ensure_list, vol.Length(min=1)), + vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_OPTIONS, default=DEFAULT_OPTIONS): cv.string }) @@ -41,9 +41,7 @@ def get_scanner(hass, config): """Validate the configuration and return a Nmap scanner.""" - scanner = NmapDeviceScanner(config[DOMAIN]) - - return scanner if scanner.success_init else None + return NmapDeviceScanner(config[DOMAIN]) Device = namedtuple('Device', ['mac', 'name', 'ip', 'last_update']) @@ -76,24 +74,32 @@ def __init__(self, config): self._options = config[CONF_OPTIONS] self.home_interval = timedelta(minutes=minutes) - self.success_init = self._update_info() _LOGGER.info("Scanner initialized") def scan_devices(self): """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, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [device.name for device in self.last_results - if device.mac == mac] + filter_named = [result.name for result in self.last_results + if result.mac == device] if filter_named: 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/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index b23008336ac06..e99524c36db61 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -1,57 +1,61 @@ """ -Support the OwnTracks platform. +Device tracker platform that adds support for OwnTracks over MQTT. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.owntracks/ """ import asyncio +import base64 import json import logging -import base64 from collections import defaultdict import voluptuous as vol -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv import homeassistant.components.mqtt as mqtt -from homeassistant.const import STATE_HOME -from homeassistant.util import convert, slugify +import homeassistant.helpers.config_validation as cv from homeassistant.components import zone as zone_comp -from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, + SOURCE_TYPE_GPS +) +from homeassistant.const import STATE_HOME +from homeassistant.core import callback +from homeassistant.util import slugify, decorator -DEPENDENCIES = ['mqtt'] -REQUIREMENTS = ['libnacl==1.5.2'] +REQUIREMENTS = ['libnacl==1.6.1'] _LOGGER = logging.getLogger(__name__) +HANDLERS = decorator.Registry() + BEACON_DEV_ID = 'beacon' CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' CONF_SECRET = 'secret' CONF_WAYPOINT_IMPORT = 'waypoints' CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' +CONF_MQTT_TOPIC = 'mqtt_topic' +CONF_REGION_MAPPING = 'region_mapping' +CONF_EVENTS_ONLY = 'events_only' -EVENT_TOPIC = 'owntracks/+/+/event' - -LOCATION_TOPIC = 'owntracks/+/+' - -VALIDATE_LOCATION = 'location' -VALIDATE_TRANSITION = 'transition' -VALIDATE_WAYPOINTS = 'waypoints' +DEPENDENCIES = ['mqtt'] -WAYPOINT_LAT_KEY = 'lat' -WAYPOINT_LON_KEY = 'lon' -WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint' +DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' +REGION_MAPPING = {} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, + vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, + vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): + mqtt.valid_subscribe_topic, vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( cv.ensure_list, [cv.string]), vol.Optional(CONF_SECRET): vol.Any( vol.Schema({vol.Optional(cv.string): cv.string}), - cv.string) + cv.string), + vol.Optional(CONF_REGION_MAPPING, default=REGION_MAPPING): dict }) @@ -72,300 +76,80 @@ def decrypt(ciphertext, key): @asyncio.coroutine def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up an OwnTracks tracker.""" - max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - waypoint_import = config.get(CONF_WAYPOINT_IMPORT) - waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) - secret = config.get(CONF_SECRET) - - mobile_beacons_active = defaultdict(list) - regions_entered = defaultdict(list) - - def decrypt_payload(topic, ciphertext): - """Decrypt encrypted payload.""" - try: - keylen, decrypt = get_cipher() - except OSError: - _LOGGER.warning( - "Ignoring encrypted payload because libsodium not installed") - return None - - if isinstance(secret, dict): - key = secret.get(topic) - else: - key = secret - - if key is None: - _LOGGER.warning( - "Ignoring encrypted payload because no decryption key known " - "for topic %s", topic) - return None - - key = key.encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') - - try: - ciphertext = base64.b64decode(ciphertext) - message = decrypt(ciphertext, key) - message = message.decode("utf-8") - _LOGGER.debug("Decrypted payload: %s", message) - return message - except ValueError: - _LOGGER.warning( - "Ignoring encrypted payload because unable to decrypt using " - "key for topic %s", topic) - return None + context = context_from_config(async_see, config) - def validate_payload(topic, payload, data_type): - """Validate the OwnTracks payload.""" + @asyncio.coroutine + def async_handle_mqtt_message(topic, payload, qos): + """Handle incoming OwnTracks message.""" try: - data = json.loads(payload) + message = json.loads(payload) except ValueError: # If invalid JSON _LOGGER.error("Unable to parse payload as JSON: %s", payload) - return None - - if isinstance(data, dict) and \ - data.get('_type') == 'encrypted' and \ - 'data' in data: - plaintext_payload = decrypt_payload(topic, data['data']) - if plaintext_payload is None: - return None - return validate_payload(topic, plaintext_payload, data_type) - - if not isinstance(data, dict) or data.get('_type') != data_type: - _LOGGER.debug("Skipping %s update for following data " - "because of missing or malformatted data: %s", - data_type, data) - return None - if data_type == VALIDATE_TRANSITION or data_type == VALIDATE_WAYPOINTS: - return data - if max_gps_accuracy is not None and \ - convert(data.get('acc'), float, 0.0) > max_gps_accuracy: - _LOGGER.info("Ignoring %s update because expected GPS " - "accuracy %s is not met: %s", - data_type, max_gps_accuracy, payload) - return None - if convert(data.get('acc'), float, 1.0) == 0.0: - _LOGGER.warning( - "Ignoring %s update because GPS accuracy is zero: %s", - data_type, payload) - return None - - return data - - @callback - def async_owntracks_location_update(topic, payload, qos): - """MQTT message received.""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typelocation - data = validate_payload(topic, payload, VALIDATE_LOCATION) - if not data: return - dev_id, kwargs = _parse_see_args(topic, data) - - if regions_entered[dev_id]: - _LOGGER.debug( - "Location update ignored, inside region %s", - regions_entered[-1]) - return + message['topic'] = topic - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) + yield from async_handle_message(hass, context, message) - @callback - def async_owntracks_event_update(topic, payload, qos): - """Handle MQTT event (geofences).""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typetransition - data = validate_payload(topic, payload, VALIDATE_TRANSITION) - if not data: - return - - if data.get('desc') is None: - _LOGGER.error( - "Location missing from `Entering/Leaving` message - " - "please turn `Share` on in OwnTracks app") - return - # OwnTracks uses - at the start of a beacon zone - # to switch on 'hold mode' - ignore this - location = data['desc'].lstrip("-") - if location.lower() == 'home': - location = STATE_HOME - - dev_id, kwargs = _parse_see_args(topic, data) - - def enter_event(): - """Execute enter event.""" - zone = hass.states.get("zone.{}".format(slugify(location))) - if zone is None and data.get('t') == 'b': - # Not a HA zone, and a beacon so assume mobile - beacons = mobile_beacons_active[dev_id] - if location not in beacons: - beacons.append(location) - _LOGGER.info("Added beacon %s", location) - else: - # Normal region - regions = regions_entered[dev_id] - if location not in regions: - regions.append(location) - _LOGGER.info("Enter region %s", location) - _set_gps_from_zone(kwargs, location, zone) - - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - def leave_event(): - """Execute leave event.""" - regions = regions_entered[dev_id] - if location in regions: - regions.remove(location) - new_region = regions[-1] if regions else None - - if new_region: - # Exit to previous region - zone = hass.states.get( - "zone.{}".format(slugify(new_region))) - _set_gps_from_zone(kwargs, new_region, zone) - _LOGGER.info("Exit to %s", new_region) - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - else: - _LOGGER.info("Exit to GPS") - # Check for GPS accuracy - valid_gps = True - if 'acc' in data: - if data['acc'] == 0.0: - valid_gps = False - _LOGGER.warning( - "Ignoring GPS in region exit because accuracy" - "is zero: %s", payload) - if (max_gps_accuracy is not None and - data['acc'] > max_gps_accuracy): - valid_gps = False - _LOGGER.info( - "Ignoring GPS in region exit because expected " - "GPS accuracy %s is not met: %s", - max_gps_accuracy, payload) - if valid_gps: - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - beacons = mobile_beacons_active[dev_id] - if location in beacons: - beacons.remove(location) - _LOGGER.info("Remove beacon %s", location) - - if data['event'] == 'enter': - enter_event() - elif data['event'] == 'leave': - leave_event() - else: - _LOGGER.error( - "Misformatted mqtt msgs, _type=transition, event=%s", - data['event']) - return - - @callback - def async_owntracks_waypoint_update(topic, payload, qos): - """List of waypoints published by a user.""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typewaypoints - data = validate_payload(topic, payload, VALIDATE_WAYPOINTS) - if not data: - return - - wayps = data['waypoints'] - _LOGGER.info("Got %d waypoints from %s", len(wayps), topic) - for wayp in wayps: - name = wayp['desc'] - pretty_name = parse_topic(topic, True)[1] + ' - ' + name - lat = wayp[WAYPOINT_LAT_KEY] - lon = wayp[WAYPOINT_LON_KEY] - rad = wayp['rad'] - - # check zone exists - entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) - - # Check if state already exists - if hass.states.get(entity_id) is not None: - continue - - zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, - zone_comp.ICON_IMPORT, False) - zone.entity_id = entity_id - hass.async_add_job(zone.async_update_ha_state()) - - @callback - def async_see_beacons(dev_id, kwargs_param): - """Set active beacons to the current location.""" - kwargs = kwargs_param.copy() - # the battery state applies to the tracking device, not the beacon - kwargs.pop('battery', None) - for beacon in mobile_beacons_active[dev_id]: - kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) - kwargs['host_name'] = beacon - hass.async_add_job(async_see(**kwargs)) - - yield from mqtt.async_subscribe( - hass, LOCATION_TOPIC, async_owntracks_location_update, 1) yield from mqtt.async_subscribe( - hass, EVENT_TOPIC, async_owntracks_event_update, 1) - - if waypoint_import: - if waypoint_whitelist is None: - yield from mqtt.async_subscribe( - hass, WAYPOINT_TOPIC.format('+', '+'), - async_owntracks_waypoint_update, 1) - else: - for whitelist_user in waypoint_whitelist: - yield from mqtt.async_subscribe( - hass, WAYPOINT_TOPIC.format(whitelist_user, '+'), - async_owntracks_waypoint_update, 1) + hass, context.mqtt_topic, async_handle_mqtt_message, 1) return True -def parse_topic(topic, pretty=False): - """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple. +def _parse_topic(topic, subscribe_topic): + """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. Async friendly. """ - parts = topic.split('/') - dev_id_format = '' - if pretty: - dev_id_format = '{} {}' - else: - dev_id_format = '{}_{}' - dev_id = slugify(dev_id_format.format(parts[1], parts[2])) - host_name = parts[1] - return (host_name, dev_id) + subscription = subscribe_topic.split('/') + try: + user_index = subscription.index('#') + except ValueError: + _LOGGER.error("Can't parse subscription topic: '%s'", subscribe_topic) + raise + + topic_list = topic.split('/') + try: + user, device = topic_list[user_index], topic_list[user_index + 1] + except IndexError: + _LOGGER.error("Can't parse topic: '%s'", topic) + raise + return user, device -def _parse_see_args(topic, data): + +def _parse_see_args(message, subscribe_topic): """Parse the OwnTracks location parameters, into the format see expects. Async friendly. """ - (host_name, dev_id) = parse_topic(topic, False) + user, device = _parse_topic(message['topic'], subscribe_topic) + dev_id = slugify('{}_{}'.format(user, device)) kwargs = { 'dev_id': dev_id, - 'host_name': host_name, - 'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY]), + 'host_name': user, + 'gps': (message['lat'], message['lon']), 'attributes': {} } - if 'acc' in data: - kwargs['gps_accuracy'] = data['acc'] - if 'batt' in data: - kwargs['battery'] = data['batt'] - if 'vel' in data: - kwargs['attributes']['velocity'] = data['vel'] - if 'tid' in data: - kwargs['attributes']['tid'] = data['tid'] - if 'addr' in data: - kwargs['attributes']['address'] = data['addr'] + if 'acc' in message: + kwargs['gps_accuracy'] = message['acc'] + if 'batt' in message: + kwargs['battery'] = message['batt'] + if 'vel' in message: + kwargs['attributes']['velocity'] = message['vel'] + if 'tid' in message: + kwargs['attributes']['tid'] = message['tid'] + if 'addr' in message: + kwargs['attributes']['address'] = message['addr'] + if 'cog' in message: + kwargs['attributes']['course'] = message['cog'] + if 't' in message: + if message['t'] == 'c': + kwargs['attributes'][ATTR_SOURCE_TYPE] = SOURCE_TYPE_GPS + if message['t'] == 'b': + kwargs['attributes'][ATTR_SOURCE_TYPE] = SOURCE_TYPE_BLUETOOTH_LE return dev_id, kwargs @@ -382,3 +166,339 @@ def _set_gps_from_zone(kwargs, location, zone): kwargs['gps_accuracy'] = zone.attributes['radius'] kwargs['location_name'] = location return kwargs + + +def _decrypt_payload(secret, topic, ciphertext): + """Decrypt encrypted payload.""" + try: + keylen, decrypt = get_cipher() + except OSError: + _LOGGER.warning( + "Ignoring encrypted payload because libsodium not installed") + return None + + if isinstance(secret, dict): + key = secret.get(topic) + else: + key = secret + + if key is None: + _LOGGER.warning( + "Ignoring encrypted payload because no decryption key known " + "for topic %s", topic) + return None + + key = key.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + try: + ciphertext = base64.b64decode(ciphertext) + message = decrypt(ciphertext, key) + message = message.decode("utf-8") + _LOGGER.debug("Decrypted payload: %s", message) + return message + except ValueError: + _LOGGER.warning( + "Ignoring encrypted payload because unable to decrypt using " + "key for topic %s", topic) + return None + + +def context_from_config(async_see, config): + """Create an async context from Home Assistant config.""" + max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + waypoint_import = config.get(CONF_WAYPOINT_IMPORT) + waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) + secret = config.get(CONF_SECRET) + region_mapping = config.get(CONF_REGION_MAPPING) + events_only = config.get(CONF_EVENTS_ONLY) + mqtt_topic = config.get(CONF_MQTT_TOPIC) + + return OwnTracksContext(async_see, secret, max_gps_accuracy, + waypoint_import, waypoint_whitelist, + region_mapping, events_only, mqtt_topic) + + +class OwnTracksContext: + """Hold the current OwnTracks context.""" + + def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, + waypoint_whitelist, region_mapping, events_only, mqtt_topic): + """Initialize an OwnTracks context.""" + self.async_see = async_see + self.secret = secret + self.max_gps_accuracy = max_gps_accuracy + self.mobile_beacons_active = defaultdict(set) + self.regions_entered = defaultdict(list) + self.import_waypoints = import_waypoints + self.waypoint_whitelist = waypoint_whitelist + self.region_mapping = region_mapping + self.events_only = events_only + self.mqtt_topic = mqtt_topic + + @callback + def async_valid_accuracy(self, message): + """Check if we should ignore this message.""" + acc = message.get('acc') + + if acc is None: + return False + + try: + acc = float(acc) + except ValueError: + return False + + if acc == 0: + _LOGGER.warning( + "Ignoring %s update because GPS accuracy is zero: %s", + message['_type'], message) + return False + + if self.max_gps_accuracy is not None and \ + acc > self.max_gps_accuracy: + _LOGGER.info("Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + message['_type'], self.max_gps_accuracy, + message) + return False + + return True + + @asyncio.coroutine + def async_see_beacons(self, hass, dev_id, kwargs_param): + """Set active beacons to the current location.""" + kwargs = kwargs_param.copy() + + # Mobile beacons should always be set to the location of the + # tracking device. I get the device state and make the necessary + # changes to kwargs. + device_tracker_state = hass.states.get( + "device_tracker.{}".format(dev_id)) + + if device_tracker_state is not None: + acc = device_tracker_state.attributes.get("gps_accuracy") + lat = device_tracker_state.attributes.get("latitude") + lon = device_tracker_state.attributes.get("longitude") + kwargs['gps_accuracy'] = acc + kwargs['gps'] = (lat, lon) + + # the battery state applies to the tracking device, not the beacon + # kwargs location is the beacon's configured lat/lon + kwargs.pop('battery', None) + for beacon in self.mobile_beacons_active[dev_id]: + kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) + kwargs['host_name'] = beacon + yield from self.async_see(**kwargs) + + +@HANDLERS.register('location') +@asyncio.coroutine +def async_handle_location_message(hass, context, message): + """Handle a location message.""" + if not context.async_valid_accuracy(message): + return + + if context.events_only: + _LOGGER.debug("Location update ignored due to events_only setting") + return + + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) + + if context.regions_entered[dev_id]: + _LOGGER.debug( + "Location update ignored, inside region %s", + context.regions_entered[-1]) + return + + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(hass, dev_id, kwargs) + + +@asyncio.coroutine +def _async_transition_message_enter(hass, context, message, location): + """Execute enter event.""" + zone = hass.states.get("zone.{}".format(slugify(location))) + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) + + if zone is None and message.get('t') == 'b': + # Not a HA zone, and a beacon so mobile beacon. + # kwargs will contain the lat/lon of the beacon + # which is not where the beacon actually is + # and is probably set to 0/0 + beacons = context.mobile_beacons_active[dev_id] + if location not in beacons: + beacons.add(location) + _LOGGER.info("Added beacon %s", location) + yield from context.async_see_beacons(hass, dev_id, kwargs) + else: + # Normal region + regions = context.regions_entered[dev_id] + if location not in regions: + regions.append(location) + _LOGGER.info("Enter region %s", location) + _set_gps_from_zone(kwargs, location, zone) + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(hass, dev_id, kwargs) + + +@asyncio.coroutine +def _async_transition_message_leave(hass, context, message, location): + """Execute leave event.""" + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) + regions = context.regions_entered[dev_id] + + if location in regions: + regions.remove(location) + + beacons = context.mobile_beacons_active[dev_id] + if location in beacons: + beacons.remove(location) + _LOGGER.info("Remove beacon %s", location) + yield from context.async_see_beacons(hass, dev_id, kwargs) + else: + new_region = regions[-1] if regions else None + if new_region: + # Exit to previous region + zone = hass.states.get( + "zone.{}".format(slugify(new_region))) + _set_gps_from_zone(kwargs, new_region, zone) + _LOGGER.info("Exit to %s", new_region) + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(hass, dev_id, kwargs) + return + + _LOGGER.info("Exit to GPS") + + # Check for GPS accuracy + if context.async_valid_accuracy(message): + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(hass, dev_id, kwargs) + + +@HANDLERS.register('transition') +@asyncio.coroutine +def async_handle_transition_message(hass, context, message): + """Handle a transition message.""" + if message.get('desc') is None: + _LOGGER.error( + "Location missing from `Entering/Leaving` message - " + "please turn `Share` on in OwnTracks app") + return + # OwnTracks uses - at the start of a beacon zone + # to switch on 'hold mode' - ignore this + location = message['desc'].lstrip("-") + + # Create a layer of indirection for Owntracks instances that may name + # regions differently than their HA names + if location in context.region_mapping: + location = context.region_mapping[location] + + if location.lower() == 'home': + location = STATE_HOME + + if message['event'] == 'enter': + yield from _async_transition_message_enter( + hass, context, message, location) + elif message['event'] == 'leave': + yield from _async_transition_message_leave( + hass, context, message, location) + else: + _LOGGER.error( + "Misformatted mqtt msgs, _type=transition, event=%s", + message['event']) + + +@asyncio.coroutine +def async_handle_waypoint(hass, name_base, waypoint): + """Handle a waypoint.""" + name = waypoint['desc'] + pretty_name = '{} - {}'.format(name_base, name) + lat = waypoint['lat'] + lon = waypoint['lon'] + rad = waypoint['rad'] + + # check zone exists + entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) + + # Check if state already exists + if hass.states.get(entity_id) is not None: + return + + zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, + zone_comp.ICON_IMPORT, False) + zone.entity_id = entity_id + yield from zone.async_update_ha_state() + + +@HANDLERS.register('waypoint') +@HANDLERS.register('waypoints') +@asyncio.coroutine +def async_handle_waypoints_message(hass, context, message): + """Handle a waypoints message.""" + if not context.import_waypoints: + return + + if context.waypoint_whitelist is not None: + user = _parse_topic(message['topic'], context.mqtt_topic)[0] + + if user not in context.waypoint_whitelist: + return + + if 'waypoints' in message: + wayps = message['waypoints'] + else: + wayps = [message] + + _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) + + name_base = ' '.join(_parse_topic(message['topic'], context.mqtt_topic)) + + for wayp in wayps: + yield from async_handle_waypoint(hass, name_base, wayp) + + +@HANDLERS.register('encrypted') +@asyncio.coroutine +def async_handle_encrypted_message(hass, context, message): + """Handle an encrypted message.""" + plaintext_payload = _decrypt_payload(context.secret, message['topic'], + message['data']) + + if plaintext_payload is None: + return + + decrypted = json.loads(plaintext_payload) + decrypted['topic'] = message['topic'] + + yield from async_handle_message(hass, context, decrypted) + + +@HANDLERS.register('lwt') +@HANDLERS.register('configuration') +@HANDLERS.register('beacon') +@HANDLERS.register('cmd') +@HANDLERS.register('steps') +@HANDLERS.register('card') +@asyncio.coroutine +def async_handle_not_impl_msg(hass, context, message): + """Handle valid but not implemented message types.""" + _LOGGER.debug('Not handling %s message: %s', message.get("_type"), message) + + +@asyncio.coroutine +def async_handle_unsupported_msg(hass, context, message): + """Handle an unsupported or invalid message type.""" + _LOGGER.warning('Received unsupported message type: %s.', + message.get('_type')) + + +@asyncio.coroutine +def async_handle_message(hass, context, message): + """Handle an OwnTracks message.""" + msgtype = message.get('_type') + + handler = HANDLERS.get(msgtype, async_handle_unsupported_msg) + + yield from handler(hass, context, message) diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py new file mode 100644 index 0000000000000..d74e1fc6d95bf --- /dev/null +++ b/homeassistant/components/device_tracker/owntracks_http.py @@ -0,0 +1,58 @@ +""" +Device tracker platform that adds support for OwnTracks over HTTP. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.owntracks_http/ +""" +import asyncio +import re + +from aiohttp.web_exceptions import HTTPInternalServerError + +from homeassistant.components.http import HomeAssistantView + +# pylint: disable=unused-import +from .owntracks import ( # NOQA + REQUIREMENTS, PLATFORM_SCHEMA, context_from_config, async_handle_message) + + +DEPENDENCIES = ['http'] + + +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up an OwnTracks tracker.""" + context = context_from_config(async_see, config) + + hass.http.register_view(OwnTracksView(context)) + + return True + + +class OwnTracksView(HomeAssistantView): + """View to handle OwnTracks HTTP requests.""" + + url = '/api/owntracks/{user}/{device}' + name = 'api:owntracks' + + def __init__(self, context): + """Initialize OwnTracks URL endpoints.""" + self.context = context + + @asyncio.coroutine + def post(self, request, user, device): + """Handle an OwnTracks message.""" + hass = request.app['hass'] + + subscription = self.context.mqtt_topic + topic = re.sub('/#$', '', subscription) + + message = yield from request.json() + message['topic'] = '{}/{}/{}'.format(topic, user, device) + + try: + yield from async_handle_message(hass, self.context, message) + return self.json([]) + + except ValueError: + raise HTTPInternalServerError diff --git a/homeassistant/components/device_tracker/ping.py b/homeassistant/components/device_tracker/ping.py index 36f1ea06fd639..6a0cb18d55ec6 100644 --- a/homeassistant/components/device_tracker/ping.py +++ b/homeassistant/components/device_tracker/ping.py @@ -13,8 +13,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, DEFAULT_SCAN_INTERVAL, SOURCE_TYPE_ROUTER) -from homeassistant.helpers.event import track_point_in_utc_time + PLATFORM_SCHEMA, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, + SOURCE_TYPE_ROUTER) from homeassistant import util from homeassistant import const @@ -70,16 +70,21 @@ def setup_scanner(hass, config, see, discovery_info=None): """Set up the Host objects and return the update function.""" hosts = [Host(ip, dev_id, hass, config) for (dev_id, ip) in config[const.CONF_HOSTS].items()] - interval = timedelta(seconds=len(hosts) * config[CONF_PING_COUNT]) + \ - DEFAULT_SCAN_INTERVAL - _LOGGER.info("Started ping tracker with interval=%s on hosts: %s", - interval, ",".join([host.ip_address for host in hosts])) - - def update(now): + interval = config.get(CONF_SCAN_INTERVAL, + timedelta(seconds=len(hosts) * + config[CONF_PING_COUNT]) + + DEFAULT_SCAN_INTERVAL) + _LOGGER.debug("Started ping tracker with interval=%s on hosts: %s", + interval, ",".join([host.ip_address for host in hosts])) + + def update_interval(now): """Update all the hosts on every interval time.""" - for host in hosts: - host.update(see) - track_point_in_utc_time(hass, update, util.dt.utcnow() + interval) - return True - - return update(util.dt.utcnow()) + try: + for host in hosts: + host.update(see) + finally: + hass.helpers.event.track_point_in_utc_time( + update_interval, util.dt.utcnow() + interval) + + update_interval(None) + return True diff --git a/homeassistant/components/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml index 2d3315b319a12..7436bbd6ea404 100644 --- a/homeassistant/components/device_tracker/services.yaml +++ b/homeassistant/components/device_tracker/services.yaml @@ -1,41 +1,33 @@ # Describes the format for available device tracker services see: - description: Control tracked device - + description: Control tracked device. fields: mac: description: MAC address of device example: 'FF:FF:FF:FF:FF:FF' - dev_id: - description: Id of device (find id in known_devices.yaml) + description: Id of device (find id in known_devices.yaml). example: 'phonedave' - host_name: description: Hostname of device example: 'Dave' - location_name: - description: Name of location where device is located (not_home is away) + description: Name of location where device is located (not_home is away). example: 'home' - gps: - description: GPS coordinates where device is located (latitude, longitude) + description: GPS coordinates where device is located (latitude, longitude). example: '[51.509802, -0.086692]' - gps_accuracy: - description: Accuracy of GPS coordinates + description: Accuracy of GPS coordinates. example: '80' - battery: - description: Battery level of device + description: Battery level of device. example: '100' icloud: icloud_lost_iphone: - description: Service to play the lost iphone sound on an iDevice - + description: Service to play the lost iphone sound on an iDevice. fields: account_name: description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts. @@ -43,10 +35,8 @@ icloud: device_name: description: Name of the device that will play the sound. This is optional, if it isn't given it will play on all devices for the given account. example: 'iphonebart' - icloud_set_interval: - description: Service to set the interval of an iDevice - + description: Service to set the interval of an iDevice. fields: account_name: description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts. @@ -57,10 +47,8 @@ icloud: interval: description: The interval (in minutes) that the iDevice will have until the according device_tracker entity changes from zone or until this service is used again. This is optional, if it isn't given the interval of the device will revert back to the original interval based on the current state. example: 1 - icloud_update: description: Service to ask for an update of an iDevice. - fields: account_name: description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts. @@ -68,10 +56,8 @@ icloud: device_name: description: Name of the device that will be updated. This is optional, if it isn't given it will update all devices for the given account. example: 'iphonebart' - icloud_reset_account: description: Service to restart an iCloud account. Helpful when not all devices are found after initializing or when you add a new device. - fields: account_name: description: Name of the account in the config that will be restarted. This is optional, if it isn't given it will restart all accounts. diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 3efae2b9ce2e5..c9c27fb2bfa84 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -14,29 +14,29 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['pysnmp==4.4.4'] -REQUIREMENTS = ['pysnmp==4.3.9'] +_LOGGER = logging.getLogger(__name__) -CONF_COMMUNITY = 'community' CONF_AUTHKEY = 'authkey' -CONF_PRIVKEY = 'privkey' CONF_BASEOID = 'baseoid' +CONF_COMMUNITY = 'community' +CONF_PRIVKEY = 'privkey' DEFAULT_COMMUNITY = 'public' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_BASEOID): cv.string, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string, vol.Inclusive(CONF_AUTHKEY, 'keys'): cv.string, vol.Inclusive(CONF_PRIVKEY, 'keys'): cv.string, - vol.Required(CONF_BASEOID): cv.string }) # pylint: disable=unused-argument def get_scanner(hass, config): - """Validate the configuration and return an snmp scanner.""" + """Validate the configuration and return an SNMP scanner.""" scanner = SnmpScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -75,7 +75,7 @@ def scan_devices(self): return [client['mac'] for client in self.last_results if client.get('mac')] - # Supressing no-self-use warning + # Suppressing no-self-use warning # pylint: disable=R0201 def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" diff --git a/homeassistant/components/device_tracker/swisscom.py b/homeassistant/components/device_tracker/swisscom.py index e64d30942ca92..d5826ecedff66 100644 --- a/homeassistant/components/device_tracker/swisscom.py +++ b/homeassistant/components/device_tracker/swisscom.py @@ -6,13 +6,14 @@ """ import logging +from aiohttp.hdrs import CONTENT_TYPE import requests 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 +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -77,7 +78,7 @@ def _update_info(self): def get_swisscom_data(self): """Retrieve data from Swisscom and return parsed result.""" url = 'http://{}/ws'.format(self.host) - headers = {'Content-Type': 'application/x-sah-ws-4-call+json'} + headers = {CONTENT_TYPE: 'application/x-sah-ws-4-call+json'} data = """ {"service":"Devices", "method":"get", "parameters":{"expression":"lan and not self"}}""" diff --git a/homeassistant/components/device_tracker/tado.py b/homeassistant/components/device_tracker/tado.py index fca4998f7b5d5..ef816338ce970 100644 --- a/homeassistant/components/device_tracker/tado.py +++ b/homeassistant/components/device_tracker/tado.py @@ -68,32 +68,28 @@ def __init__(self, hass, config): self.websession = async_create_clientsession( hass, cookie_jar=aiohttp.CookieJar(unsafe=True, loop=hass.loop)) - self.success_init = self._update_info() + self.success_init = asyncio.run_coroutine_threadsafe( + self._async_update_info(), hass.loop + ).result() + _LOGGER.info("Scanner initialized") - @asyncio.coroutine - def async_scan_devices(self): + async def async_scan_devices(self): """Scan for devices and return a list containing found device ids.""" - info = self._update_info() - - # Don't yield if we got None - if info is not None: - yield from info - + await self._async_update_info() return [device.mac for device in self.last_results] - @asyncio.coroutine - def async_get_device_name(self, mac): + async def async_get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [device.name for device in self.last_results - if device.mac == mac] + filter_named = [result.name for result in self.last_results + if result.mac == device] if filter_named: return filter_named[0] return None @Throttle(MIN_TIME_BETWEEN_SCANS) - def _update_info(self): + async def _async_update_info(self): """ Query Tado for device marked as at home. @@ -104,21 +100,21 @@ def _update_info(self): 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( home_id=self.home_id, username=self.username, password=self.password) - response = yield from self.websession.get(url) + response = await self.websession.get(url) if response.status != 200: _LOGGER.warning( "Error %d on %s.", response.status, self.tadoapiurl) - return + return False - tado_json = yield from response.json() + tado_json = await response.json() except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Cannot load Tado data") @@ -139,7 +135,7 @@ def _update_info(self): self.last_results = last_results - _LOGGER.info( + _LOGGER.debug( "Tado presence query successful, %d device(s) at home", len(self.last_results) ) diff --git a/homeassistant/components/device_tracker/tesla.py b/homeassistant/components/device_tracker/tesla.py index 4945e98a94d6c..ba9bc8c2631a8 100644 --- a/homeassistant/components/device_tracker/tesla.py +++ b/homeassistant/components/device_tracker/tesla.py @@ -44,14 +44,15 @@ def _update_info(self, now=None): _LOGGER.debug("Updating device position: %s", name) dev_id = slugify(device.uniq_name) location = device.get_location() - lat = location['latitude'] - lon = location['longitude'] - attrs = { - 'trackr_id': dev_id, - 'id': dev_id, - 'name': name - } - self.see( - dev_id=dev_id, host_name=name, - gps=(lat, lon), attributes=attrs - ) + if location: + lat = location['latitude'] + lon = location['longitude'] + attrs = { + 'trackr_id': dev_id, + 'id': dev_id, + 'name': name + } + self.see( + dev_id=dev_id, host_name=name, + gps=(lat, lon), attributes=attrs + ) diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py new file mode 100644 index 0000000000000..377686b69054f --- /dev/null +++ b/homeassistant/components/device_tracker/tile.py @@ -0,0 +1,122 @@ +""" +Support for Tile® Bluetooth trackers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.tile/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_USERNAME, CONF_MONITORED_VARIABLES, CONF_PASSWORD) +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import slugify +from homeassistant.util.json import load_json, save_json + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['pytile==1.1.0'] + +CLIENT_UUID_CONFIG_FILE = '.tile.conf' +DEFAULT_ICON = 'mdi:bluetooth' +DEVICE_TYPES = ['PHONE', 'TILE'] + +ATTR_ALTITUDE = 'altitude' +ATTR_CONNECTION_STATE = 'connection_state' +ATTR_IS_DEAD = 'is_dead' +ATTR_IS_LOST = 'is_lost' +ATTR_RING_STATE = 'ring_state' +ATTR_VOIP_STATE = 'voip_state' + +CONF_SHOW_INACTIVE = 'show_inactive' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SHOW_INACTIVE, default=False): cv.boolean, + vol.Optional(CONF_MONITORED_VARIABLES): + vol.All(cv.ensure_list, [vol.In(DEVICE_TYPES)]), +}) + + +def setup_scanner(hass, config: dict, see, discovery_info=None): + """Validate the configuration and return a Tile scanner.""" + TileDeviceScanner(hass, config, see) + return True + + +class TileDeviceScanner(DeviceScanner): + """Define a device scanner for Tiles.""" + + def __init__(self, hass, config, see): + """Initialize.""" + from pytile import Client + + _LOGGER.debug('Received configuration data: %s', config) + + # Load the client UUID (if it exists): + config_data = load_json(hass.config.path(CLIENT_UUID_CONFIG_FILE)) + if config_data: + _LOGGER.debug('Using existing client UUID') + self._client = Client( + config[CONF_USERNAME], + config[CONF_PASSWORD], + config_data['client_uuid']) + else: + _LOGGER.debug('Generating new client UUID') + self._client = Client( + config[CONF_USERNAME], + config[CONF_PASSWORD]) + + if not save_json( + hass.config.path(CLIENT_UUID_CONFIG_FILE), + {'client_uuid': self._client.client_uuid}): + _LOGGER.error("Failed to save configuration file") + + _LOGGER.debug('Client UUID: %s', self._client.client_uuid) + _LOGGER.debug('User UUID: %s', self._client.user_uuid) + + self._show_inactive = config.get(CONF_SHOW_INACTIVE) + self._types = config.get(CONF_MONITORED_VARIABLES) + + self.devices = {} + self.see = see + + track_utc_time_change( + hass, self._update_info, second=range(0, 60, 30)) + + self._update_info() + + def _update_info(self, now=None) -> None: + """Update the device info.""" + self.devices = self._client.get_tiles( + type_whitelist=self._types, show_inactive=self._show_inactive) + + if not self.devices: + _LOGGER.warning('No Tiles found') + return + + for dev in self.devices: + dev_id = 'tile_{0}'.format(slugify(dev['name'])) + lat = dev['tileState']['latitude'] + lon = dev['tileState']['longitude'] + + attrs = { + ATTR_ALTITUDE: dev['tileState']['altitude'], + ATTR_CONNECTION_STATE: dev['tileState']['connection_state'], + ATTR_IS_DEAD: dev['is_dead'], + ATTR_IS_LOST: dev['tileState']['is_lost'], + ATTR_RING_STATE: dev['tileState']['ring_state'], + ATTR_VOIP_STATE: dev['tileState']['voip_state'], + } + + self.see( + dev_id=dev_id, + gps=(lat, lon), + attributes=attrs, + icon=DEFAULT_ICON + ) diff --git a/homeassistant/components/device_tracker/tomato.py b/homeassistant/components/device_tracker/tomato.py index 57e83eaeb94a9..01ae2977f6d9f 100644 --- a/homeassistant/components/device_tracker/tomato.py +++ b/homeassistant/components/device_tracker/tomato.py @@ -14,7 +14,9 @@ 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 +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL, + CONF_PASSWORD, CONF_USERNAME) CONF_HTTP_ID = 'http_id' @@ -22,6 +24,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): vol.Any( + cv.boolean, cv.isfile), vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_HTTP_ID): cv.string @@ -39,16 +45,21 @@ class TomatoDeviceScanner(DeviceScanner): def __init__(self, config): """Initialize the scanner.""" host, http_id = config[CONF_HOST], config[CONF_HTTP_ID] + port = config.get(CONF_PORT) username, password = config[CONF_USERNAME], config[CONF_PASSWORD] + self.ssl, self.verify_ssl = config[CONF_SSL], config[CONF_VERIFY_SSL] + if port is None: + port = 443 if self.ssl else 80 self.req = requests.Request( - 'POST', 'http://{}/update.cgi'.format(host), + 'POST', 'http{}://{}:{}/update.cgi'.format( + "s" if self.ssl else "", host, port + ), data={'_http_id': http_id, 'exec': 'devlist'}, auth=requests.auth.HTTPBasicAuth(username, password)).prepare() self.parse_api_pattern = re.compile(r"(?P\w*) = (?P.*);") - self.logger = logging.getLogger("{}.{}".format(__name__, "Tomato")) self.last_results = {"wldev": [], "dhcpd_lease": []} self.success_init = self._update_tomato_info() @@ -74,10 +85,16 @@ def _update_tomato_info(self): Return boolean if scanning successful. """ - self.logger.info("Scanning") + _LOGGER.info("Scanning") try: - response = requests.Session().send(self.req, timeout=3) + if self.ssl: + response = requests.Session().send(self.req, + timeout=3, + verify=self.verify_ssl) + else: + response = requests.Session().send(self.req, timeout=3) + # Calling and parsing the Tomato api here. We only need the # wldev and dhcpd_lease values. if response.status_code == 200: @@ -92,7 +109,7 @@ def _update_tomato_info(self): elif response.status_code == 401: # Authentication error - self.logger.exception(( + _LOGGER.exception(( "Failed to authenticate, " "please check your username and password")) return False @@ -100,17 +117,17 @@ def _update_tomato_info(self): except requests.exceptions.ConnectionError: # We get this if we could not connect to the router or # an invalid http_id was supplied. - self.logger.exception("Failed to connect to the router or " - "invalid http_id supplied") + _LOGGER.exception("Failed to connect to the router or " + "invalid http_id supplied") return False except requests.exceptions.Timeout: # We get this if we could not connect to the router or # an invalid http_id was supplied. - self.logger.exception("Connection to the router timed out") + _LOGGER.exception("Connection to the router timed out") return False except ValueError: # If JSON decoder could not parse the response. - self.logger.exception("Failed to parse response from router") + _LOGGER.exception("Failed to parse response from router") return False diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py old mode 100755 new mode 100644 index a52de48d061cf..6c5fb697c072d --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -5,21 +5,27 @@ https://home-assistant.io/components/device_tracker.tplink/ """ import base64 +from datetime import datetime import hashlib import logging import re -from datetime import datetime +from aiohttp.hdrs import ( + ACCEPT, COOKIE, PRAGMA, REFERER, CONNECTION, KEEP_ALIVE, USER_AGENT, + CONTENT_TYPE, CACHE_CONTROL, ACCEPT_ENCODING, ACCEPT_LANGUAGE) import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, HTTP_HEADER_X_REQUESTED_WITH) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +HTTP_HEADER_NO_CACHE = 'no-cache' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -78,7 +84,7 @@ def _update_info(self): referer = 'http://{}'.format(self.host) page = requests.get( url, auth=(self.username, self.password), - headers={'referer': referer}, timeout=4) + headers={REFERER: referer}, timeout=4) result = self.parse_macs.findall(page.text) @@ -123,7 +129,7 @@ def _update_info(self): .format(b64_encoded_username_password) response = requests.post( - url, headers={'referer': referer, 'cookie': cookie}, + url, headers={REFERER: referer, COOKIE: cookie}, timeout=4) try: @@ -174,11 +180,11 @@ def _get_auth_tokens(self): .format(self.host) referer = 'http://{}/webpages/login.html'.format(self.host) - # If possible implement rsa encryption of password here. + # If possible implement RSA encryption of password here. response = requests.post( url, params={'operation': 'login', 'username': self.username, 'password': self.password}, - headers={'referer': referer}, timeout=4) + headers={REFERER: referer}, timeout=4) try: self.stok = response.json().get('data').get('stok') @@ -207,11 +213,9 @@ def _update_info(self): 'form=statistics').format(self.host, self.stok) referer = 'http://{}/webpages/index.html'.format(self.host) - response = requests.post(url, - params={'operation': 'load'}, - headers={'referer': referer}, - cookies={'sysauth': self.sysauth}, - timeout=5) + response = requests.post( + url, params={'operation': 'load'}, headers={REFERER: referer}, + cookies={'sysauth': self.sysauth}, timeout=5) try: json_response = response.json() @@ -248,10 +252,9 @@ def _log_out(self): 'form=logout').format(self.host, self.stok) referer = 'http://{}/webpages/index.html'.format(self.host) - requests.post(url, - params={'operation': 'write'}, - headers={'referer': referer}, - cookies={'sysauth': self.sysauth}) + requests.post( + url, params={'operation': 'write'}, headers={REFERER: referer}, + cookies={'sysauth': self.sysauth}) self.stok = '' self.sysauth = '' @@ -292,7 +295,7 @@ def _get_auth_tokens(self): # Create the authorization cookie. cookie = 'Authorization=Basic {}'.format(self.credentials) - response = requests.get(url, headers={'cookie': cookie}) + response = requests.get(url, headers={COOKIE: cookie}) try: result = re.search(r'window.parent.location.href = ' @@ -326,8 +329,8 @@ def _update_info(self): cookie = 'Authorization=Basic {}'.format(self.credentials) page = requests.get(url, headers={ - 'cookie': cookie, - 'referer': referer + COOKIE: cookie, + REFERER: referer, }) mac_results.extend(self.parse_macs.findall(page.text)) @@ -361,31 +364,31 @@ def _update_info(self): base_url = 'http://{}'.format(self.host) header = { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12;" - " rv:53.0) Gecko/20100101 Firefox/53.0", - "Accept": "application/json, text/javascript, */*; q=0.01", - "Accept-Language": "Accept-Language: en-US,en;q=0.5", - "Accept-Encoding": "gzip, deflate", - "Content-Type": "application/x-www-form-urlencoded; " - "charset=UTF-8", - "X-Requested-With": "XMLHttpRequest", - "Referer": "http://" + self.host + "/", - "Connection": "keep-alive", - "Pragma": "no-cache", - "Cache-Control": "no-cache" + USER_AGENT: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12;" + " rv:53.0) Gecko/20100101 Firefox/53.0", + ACCEPT: "application/json, text/javascript, */*; q=0.01", + ACCEPT_LANGUAGE: "Accept-Language: en-US,en;q=0.5", + ACCEPT_ENCODING: "gzip, deflate", + CONTENT_TYPE: "application/x-www-form-urlencoded; charset=UTF-8", + HTTP_HEADER_X_REQUESTED_WITH: "XMLHttpRequest", + REFERER: "http://{}/".format(self.host), + CONNECTION: KEEP_ALIVE, + PRAGMA: HTTP_HEADER_NO_CACHE, + CACHE_CONTROL: HTTP_HEADER_NO_CACHE, } password_md5 = hashlib.md5( self.password.encode('utf')).hexdigest().upper() - # create a session to handle cookie easier + # Create a session to handle cookie easier session = requests.session() session.get(base_url, headers=header) login_data = {"username": self.username, "password": password_md5} session.post(base_url, login_data, headers=header) - # a timestamp is required to be sent as get parameter + # A timestamp is required to be sent as get parameter timestamp = int(datetime.now().timestamp() * 1e3) client_list_url = '{}/data/monitor.client.client.json'.format( @@ -393,18 +396,17 @@ def _update_info(self): get_params = { 'operation': 'load', - '_': timestamp + '_': timestamp, } - response = session.get(client_list_url, - headers=header, - params=get_params) + response = session.get( + client_list_url, headers=header, params=get_params) session.close() try: list_of_devices = response.json() except ValueError: _LOGGER.error("AP didn't respond with JSON. " - "Check if credentials are correct.") + "Check if credentials are correct") return False if list_of_devices: diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index 9ccc61dffc91f..f265014657bef 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -11,39 +11,55 @@ import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +CONF_DHCP_SOFTWARE = 'dhcp_software' +DEFAULT_DHCP_SOFTWARE = 'dnsmasq' +DHCP_SOFTWARES = [ + 'dnsmasq', + 'odhcpd', + 'none' +] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_DHCP_SOFTWARE, default=DEFAULT_DHCP_SOFTWARE): + vol.In(DHCP_SOFTWARES), }) def get_scanner(hass, config): """Validate the configuration and return an ubus scanner.""" - scanner = UbusDeviceScanner(config[DOMAIN]) + dhcp_sw = config[DOMAIN][CONF_DHCP_SOFTWARE] + if dhcp_sw == 'dnsmasq': + scanner = DnsmasqUbusDeviceScanner(config[DOMAIN]) + elif dhcp_sw == 'odhcpd': + scanner = OdhcpdUbusDeviceScanner(config[DOMAIN]) + else: + scanner = UbusDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None -def _refresh_on_acccess_denied(func): - """If remove rebooted, it lost our session so rebuld one and try again.""" +def _refresh_on_access_denied(func): + """If remove rebooted, it lost our session so rebuild one and try again.""" def decorator(self, *args, **kwargs): - """Wrapper function to refresh session_id on PermissionError.""" + """Wrap the function to refresh session_id on PermissionError.""" try: return func(self, *args, **kwargs) except PermissionError: _LOGGER.warning("Invalid session detected." + - " Tryign to refresh session_id and re-run the rpc") - self.session_id = _get_session_id(self.url, self.username, - self.password) + " Trying to refresh session_id and re-run RPC") + self.session_id = _get_session_id( + self.url, self.username, self.password) return func(self, *args, **kwargs) @@ -67,10 +83,9 @@ def __init__(self, config): self.last_results = {} self.url = 'http://{}/ubus'.format(host) - self.session_id = _get_session_id(self.url, self.username, - self.password) + self.session_id = _get_session_id( + self.url, self.username, self.password) self.hostapd = [] - self.leasefile = None self.mac2name = None self.success_init = self.session_id is not None @@ -79,44 +94,31 @@ def scan_devices(self): self._update_info() return self.last_results - @_refresh_on_acccess_denied - def get_device_name(self, mac): - """Return the name of the given device or None if we don't know.""" - if self.leasefile is None: - result = _req_json_rpc( - self.url, self.session_id, 'call', 'uci', 'get', - config="dhcp", type="dnsmasq") - if result: - values = result["values"].values() - self.leasefile = next(iter(values))["leasefile"] - else: - return + def _generate_mac2name(self): + """Return empty MAC to name dict. Overridden if DHCP server is set.""" + self.mac2name = dict() + @_refresh_on_access_denied + 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: - result = _req_json_rpc( - self.url, self.session_id, 'call', 'file', 'read', - path=self.leasefile) - if result: - self.mac2name = dict() - for line in result["data"].splitlines(): - hosts = line.split(" ") - self.mac2name[hosts[1].upper()] = hosts[3] - else: - # Error, handled in the _req_json_rpc - return - - return self.mac2name.get(mac.upper(), None) + self._generate_mac2name() + if self.mac2name is None: + # Generation of mac2name dictionary failed + return None + name = self.mac2name.get(device.upper(), None) + return name - @_refresh_on_acccess_denied + @_refresh_on_access_denied def _update_info(self): - """Ensure the information from the Luci router is up to date. + """Ensure the information from the router is up to date. Returns boolean if scanning successful. """ if not self.success_init: return False - _LOGGER.info("Checking ARP") + _LOGGER.info("Checking hostapd") if not self.hostapd: hostapd = _req_json_rpc( @@ -125,17 +127,73 @@ def _update_info(self): self.last_results = [] results = 0 + # for each access point for hostapd in self.hostapd: result = _req_json_rpc( self.url, self.session_id, 'call', hostapd, 'get_clients') if result: results = results + 1 - self.last_results.extend(result['clients'].keys()) + # Check for each device is authorized (valid wpa key) + for key in result['clients'].keys(): + device = result['clients'][key] + if device['authorized']: + self.last_results.append(key) return bool(results) +class DnsmasqUbusDeviceScanner(UbusDeviceScanner): + """Implement the Ubus device scanning for the dnsmasq DHCP server.""" + + def __init__(self, config): + """Initialize the scanner.""" + super(DnsmasqUbusDeviceScanner, self).__init__(config) + self.leasefile = None + + def _generate_mac2name(self): + if self.leasefile is None: + result = _req_json_rpc( + self.url, self.session_id, 'call', 'uci', 'get', + config="dhcp", type="dnsmasq") + if result: + values = result["values"].values() + self.leasefile = next(iter(values))["leasefile"] + else: + return + + result = _req_json_rpc( + self.url, self.session_id, 'call', 'file', 'read', + path=self.leasefile) + if result: + self.mac2name = dict() + for line in result["data"].splitlines(): + hosts = line.split(" ") + self.mac2name[hosts[1].upper()] = hosts[3] + else: + # Error, handled in the _req_json_rpc + return + + +class OdhcpdUbusDeviceScanner(UbusDeviceScanner): + """Implement the Ubus device scanning for the odhcp DHCP server.""" + + def _generate_mac2name(self): + result = _req_json_rpc( + self.url, self.session_id, 'call', 'dhcp', 'ipv4leases') + if result: + self.mac2name = dict() + for device in result["device"].values(): + for lease in device['leases']: + mac = lease['mac'] # mac = aabbccddeeff + # Convert it to expected format with colon + mac = ":".join(mac[i:i+2] for i in range(0, len(mac), 2)) + self.mac2name[mac.upper()] = lease['hostname'] + else: + # Error, handled in the _req_json_rpc + return + + def _req_json_rpc(url, session_id, rpcmethod, subsystem, method, **params): """Perform one JSON RPC operation.""" data = json.dumps({"jsonrpc": "2.0", @@ -149,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 a471ca5c96a10..b7efe65dd0166 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -5,6 +5,7 @@ https://home-assistant.io/components/device_tracker.unifi/ """ import logging +from datetime import timedelta import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -12,16 +13,20 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD from homeassistant.const import CONF_VERIFY_SSL +import homeassistant.util.dt as dt_util REQUIREMENTS = ['pyunifi==2.13'] _LOGGER = logging.getLogger(__name__) CONF_PORT = 'port' CONF_SITE_ID = 'site_id' +CONF_DETECTION_TIME = 'detection_time' +CONF_SSID_FILTER = 'ssid_filter' DEFAULT_HOST = 'localhost' DEFAULT_PORT = 8443 DEFAULT_VERIFY_SSL = True +DEFAULT_DETECTION_TIME = timedelta(seconds=300) NOTIFICATION_ID = 'unifi_notification' NOTIFICATION_TITLE = 'Unifi Device Tracker Setup' @@ -32,7 +37,11 @@ vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): vol.Any( + cv.boolean, cv.isfile), + vol.Optional(CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME): vol.All( + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_SSID_FILTER): vol.All(cv.ensure_list, [cv.string]) }) @@ -46,6 +55,8 @@ def get_scanner(hass, config): site_id = config[DOMAIN].get(CONF_SITE_ID) port = config[DOMAIN].get(CONF_PORT) verify_ssl = config[DOMAIN].get(CONF_VERIFY_SSL) + detection_time = config[DOMAIN].get(CONF_DETECTION_TIME) + ssid_filter = config[DOMAIN].get(CONF_SSID_FILTER) try: ctrl = Controller(host, username, password, port, version='v4', @@ -61,15 +72,18 @@ def get_scanner(hass, config): notification_id=NOTIFICATION_ID) return False - return UnifiScanner(ctrl) + return UnifiScanner(ctrl, detection_time, ssid_filter) class UnifiScanner(DeviceScanner): """Provide device_tracker support from Unifi WAP client data.""" - def __init__(self, controller): + def __init__(self, controller, detection_time: timedelta, + ssid_filter) -> None: """Initialize the scanner.""" + self._detection_time = detection_time self._controller = controller + self._ssid_filter = ssid_filter self._update() def _update(self): @@ -81,20 +95,36 @@ def _update(self): _LOGGER.error("Failed to scan clients: %s", ex) clients = [] - self._clients = {client['mac']: client for client in clients} + # Filter clients to provided SSID list + if self._ssid_filter: + clients = [client for client in clients + if 'essid' in client and + client['essid'] in self._ssid_filter] + + self._clients = { + client['mac']: client + for client in clients + if (dt_util.utcnow() - dt_util.utc_from_timestamp(float( + client['last_seen']))) < self._detection_time} def scan_devices(self): """Scan for devices.""" self._update() return self._clients.keys() - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name (if known) of the device. If a name has been set in Unifi, then return that, else return the hostname if it has been detected. """ - client = self._clients.get(mac, {}) + client = self._clients.get(device, {}) name = client.get('name') or client.get('hostname') - _LOGGER.debug("Device %s name %s", mac, name) + _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/unifi_direct.py b/homeassistant/components/device_tracker/unifi_direct.py new file mode 100644 index 0000000000000..168ab04ec6f14 --- /dev/null +++ b/homeassistant/components/device_tracker/unifi_direct.py @@ -0,0 +1,138 @@ +""" +Support for Unifi AP direct access. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.unifi_direct/ +""" +import logging +import json + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, + CONF_PORT) + +REQUIREMENTS = ['pexpect==4.0.1'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_SSH_PORT = 22 +UNIFI_COMMAND = 'mca-dump | tr -d "\n"' +UNIFI_SSID_TABLE = "vap_table" +UNIFI_CLIENT_TABLE = "sta_table" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port +}) + + +# pylint: disable=unused-argument +def get_scanner(hass, config): + """Validate the configuration and return a Unifi direct scanner.""" + scanner = UnifiDeviceScanner(config[DOMAIN]) + if not scanner.connected: + return False + return scanner + + +class UnifiDeviceScanner(DeviceScanner): + """This class queries Unifi wireless access point.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + self.port = config[CONF_PORT] + self.ssh = None + self.connected = False + self.last_results = {} + self._connect() + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + result = _response_to_json(self._get_update()) + if result: + self.last_results = result + return self.last_results.keys() + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + hostname = next(( + value.get('hostname') for key, value in self.last_results.items() + if key.upper() == device.upper()), None) + if hostname is not None: + hostname = str(hostname) + return hostname + + def _connect(self): + """Connect to the Unifi AP SSH server.""" + from pexpect import pxssh, exceptions + + self.ssh = pxssh.pxssh() + try: + self.ssh.login(self.host, self.username, + password=self.password, port=self.port) + self.connected = True + except exceptions.EOF: + _LOGGER.error("Connection refused. SSH enabled?") + self._disconnect() + + def _disconnect(self): + """Disconnect the current SSH connection.""" + # pylint: disable=broad-except + try: + self.ssh.logout() + except Exception: + pass + finally: + self.ssh = None + + self.connected = False + + def _get_update(self): + from pexpect import pxssh, exceptions + + try: + if not self.connected: + self._connect() + # If we still aren't connected at this point + # don't try to send anything to the AP. + if not self.connected: + return None + self.ssh.sendline(UNIFI_COMMAND) + self.ssh.prompt() + return self.ssh.before + except pxssh.ExceptionPxssh as err: + _LOGGER.error("Unexpected SSH error: %s", str(err)) + self._disconnect() + return None + except (AssertionError, exceptions.EOF) as err: + _LOGGER.error("Connection to AP unavailable: %s", str(err)) + self._disconnect() + return None + + +def _response_to_json(response): + try: + json_response = json.loads(str(response)[31:-1].replace("\\", "")) + _LOGGER.debug(str(json_response)) + ssid_table = json_response.get(UNIFI_SSID_TABLE) + active_clients = {} + + for ssid in ssid_table: + client_table = ssid.get(UNIFI_CLIENT_TABLE) + for client in client_table: + active_clients[client.get("mac")] = client + + return active_clients + except ValueError: + _LOGGER.error("Failed to decode response from AP.") + return {} diff --git a/homeassistant/components/device_tracker/upc_connect.py b/homeassistant/components/device_tracker/upc_connect.py index a6646c8d0a14d..ea0645e012f3a 100644 --- a/homeassistant/components/device_tracker/upc_connect.py +++ b/homeassistant/components/device_tracker/upc_connect.py @@ -6,29 +6,30 @@ """ import asyncio import logging -import xml.etree.ElementTree as ET import aiohttp +from aiohttp.hdrs import REFERER, USER_AGENT import async_timeout 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 +from homeassistant.const import CONF_HOST, HTTP_HEADER_X_REQUESTED_WITH from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +REQUIREMENTS = ['defusedxml==0.5.0'] _LOGGER = logging.getLogger(__name__) +CMD_DEVICES = 123 + DEFAULT_IP = '192.168.0.1' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_IP): cv.string, }) -CMD_DEVICES = 123 - @asyncio.coroutine def async_get_scanner(hass, config): @@ -51,11 +52,11 @@ def __init__(self, hass, config): self.token = None self.headers = { - 'X-Requested-With': 'XMLHttpRequest', - 'Referer': "http://{}/index.html".format(self.host), - 'User-Agent': ("Mozilla/5.0 (Windows NT 10.0; WOW64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/47.0.2526.106 Safari/537.36") + HTTP_HEADER_X_REQUESTED_WITH: 'XMLHttpRequest', + REFERER: "http://{}/index.html".format(self.host), + USER_AGENT: ("Mozilla/5.0 (Windows NT 10.0; WOW64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/47.0.2526.106 Safari/537.36") } self.websession = async_get_clientsession(hass) @@ -63,6 +64,8 @@ def __init__(self, hass, config): @asyncio.coroutine def async_scan_devices(self): """Scan for new devices and return a list with found device IDs.""" + import defusedxml.ElementTree as ET + if self.token is None: token_initialized = yield from self.async_initialize_token() if not token_initialized: @@ -81,7 +84,7 @@ def async_scan_devices(self): @asyncio.coroutine def async_get_device_name(self, device): - """The firmware doesn't save the name of the wireless device.""" + """Get the device name (the name of the wireless device not used).""" return None @asyncio.coroutine @@ -92,8 +95,7 @@ def async_initialize_token(self): with async_timeout.timeout(10, loop=self.hass.loop): response = yield from self.websession.get( "http://{}/common_page/login.html".format(self.host), - headers=self.headers - ) + headers=self.headers) yield from response.text() @@ -115,17 +117,15 @@ def _async_ws_function(self, function): response = yield from self.websession.post( "http://{}/xml/getter.xml".format(self.host), data="token={}&fun={}".format(self.token, function), - headers=self.headers, - allow_redirects=False - ) + headers=self.headers, allow_redirects=False) - # error? + # Error? if response.status != 200: _LOGGER.warning("Receive http code %d", response.status) self.token = None return - # load data, store token for next request + # Load data, store token for next request self.token = response.cookies['sessionToken'].value return (yield from response.text()) diff --git a/homeassistant/components/device_tracker/xiaomi.py b/homeassistant/components/device_tracker/xiaomi.py index 8b8db3da2d8b8..12e64b724dd37 100644 --- a/homeassistant/components/device_tracker/xiaomi.py +++ b/homeassistant/components/device_tracker/xiaomi.py @@ -69,7 +69,7 @@ def get_device_name(self, device): return self.mac2name.get(device.upper(), None) def _update_info(self): - """Ensure the informations from the router are up to date. + """Ensure the information from the router are up to date. Returns true if scanning successful. """ diff --git a/homeassistant/components/device_tracker/xiaomi_miio.py b/homeassistant/components/device_tracker/xiaomi_miio.py new file mode 100644 index 0000000000000..c5769253657c6 --- /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 new file mode 100644 index 0000000000000..7a0918aab25e0 --- /dev/null +++ b/homeassistant/components/dialogflow.py @@ -0,0 +1,148 @@ +""" +Support for Dialogflow webhook. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/dialogflow/ +""" +import logging + +import voluptuous as vol + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent, template +from homeassistant.components.http import HomeAssistantView + +_LOGGER = logging.getLogger(__name__) + +CONF_INTENTS = 'intents' +CONF_SPEECH = 'speech' +CONF_ACTION = 'action' +CONF_ASYNC_ACTION = 'async_action' + +DEFAULT_CONF_ASYNC_ACTION = False +DEPENDENCIES = ['http'] +DOMAIN = 'dialogflow' + +INTENTS_API_ENDPOINT = '/api/dialogflow' + +SOURCE = "Home Assistant Dialogflow" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: {} +}, extra=vol.ALLOW_EXTRA) + + +class DialogFlowError(HomeAssistantError): + """Raised when a DialogFlow error happens.""" + + +async def async_setup(hass, config): + """Set up Dialogflow component.""" + hass.http.register_view(DialogflowIntentsView) + + return True + + +class DialogflowIntentsView(HomeAssistantView): + """Handle Dialogflow requests.""" + + url = INTENTS_API_ENDPOINT + name = 'api:dialogflow' + + async def post(self, request): + """Handle Dialogflow.""" + hass = request.app['hass'] + message = await request.json() + + _LOGGER.debug("Received Dialogflow request: %s", message) + + try: + response = await async_handle_message(hass, message) + return b'' if response is None else self.json(response) + + except DialogFlowError as err: + _LOGGER.warning(str(err)) + return self.json(dialogflow_error_response( + hass, message, str(err))) + + except intent.UnknownIntent as err: + _LOGGER.warning(str(err)) + return self.json(dialogflow_error_response( + hass, message, + "This intent is not yet configured within Home Assistant.")) + + except intent.InvalidSlotInfo as err: + _LOGGER.warning(str(err)) + return self.json(dialogflow_error_response( + hass, message, + "Invalid slot information received for this intent.")) + + except intent.IntentError as err: + _LOGGER.warning(str(err)) + return self.json(dialogflow_error_response( + hass, message, "Error handling intent.")) + + +def dialogflow_error_response(hass, message, error): + """Return a response saying the error message.""" + dialogflow_response = DialogflowResponse(message['result']['parameters']) + dialogflow_response.add_speech(error) + return dialogflow_response.as_dict() + + +async def async_handle_message(hass, message): + """Handle a DialogFlow message.""" + req = message.get('result') + action_incomplete = req['actionIncomplete'] + + if action_incomplete: + return None + + action = req.get('action', '') + parameters = req.get('parameters') + dialogflow_response = DialogflowResponse(parameters) + + if action == "": + raise DialogFlowError( + "You have not defined an action in your Dialogflow intent.") + + intent_response = await intent.async_handle( + hass, DOMAIN, action, + {key: {'value': value} for key, value + in parameters.items()}) + + if 'plain' in intent_response.speech: + dialogflow_response.add_speech( + intent_response.speech['plain']['speech']) + + return dialogflow_response.as_dict() + + +class DialogflowResponse(object): + """Help generating the response for Dialogflow.""" + + def __init__(self, parameters): + """Initialize the Dialogflow response.""" + self.speech = None + self.parameters = {} + # Parameter names replace '.' and '-' for '_' + for key, value in parameters.items(): + underscored_key = key.replace('.', '_').replace('-', '_') + self.parameters[underscored_key] = value + + def add_speech(self, text): + """Add speech to the response.""" + assert self.speech is None + + if isinstance(text, template.Template): + text = text.async_render(self.parameters) + + self.speech = text + + def as_dict(self): + """Return response in a Dialogflow valid dictionary.""" + return { + 'speech': self.speech, + 'displayText': self.speech, + 'source': SOURCE, + } diff --git a/homeassistant/components/digital_ocean.py b/homeassistant/components/digital_ocean.py index 6ba2c8248592d..bd03fb019759f 100644 --- a/homeassistant/components/digital_ocean.py +++ b/homeassistant/components/digital_ocean.py @@ -13,7 +13,7 @@ from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-digitalocean==1.12'] +REQUIREMENTS = ['python-digitalocean==1.13.2'] _LOGGER = logging.getLogger(__name__) @@ -44,13 +44,19 @@ def setup(hass, config): """Set up the Digital Ocean component.""" + import digitalocean + conf = config[DOMAIN] access_token = conf.get(CONF_ACCESS_TOKEN) digital = DigitalOcean(access_token) - if not digital.manager.get_account(): - _LOGGER.error("No Digital Ocean account found for the given API Token") + try: + if not digital.manager.get_account(): + _LOGGER.error("No account found for the given API token") + return False + except digitalocean.baseapi.DataReadError: + _LOGGER.error("API token not valid for authentication") return False hass.data[DATA_DIGITAL_OCEAN] = digital diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index c757d9d1ce306..a24e82da10605 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -6,7 +6,6 @@ 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 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.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.1.0'] +REQUIREMENTS = ['netdisco==1.4.1'] DOMAIN = 'discovery' @@ -34,6 +34,20 @@ SERVICE_AXIS = 'axis' SERVICE_APPLE_TV = 'apple_tv' 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), @@ -44,7 +58,12 @@ SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), SERVICE_WINK: ('wink', None), - 'philips_hue': ('light', 'hue'), + SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), + SERVICE_TELLDUSLIVE: ('tellduslive', 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'), @@ -59,23 +78,32 @@ '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'), +} + +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 @@ -89,40 +117,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 + 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 - discovery_hash = json.dumps([service, info], sort_keys=True) - if discovery_hash in already_discovered: - return - - already_discovered.add(discovery_hash) - 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)) @@ -153,6 +193,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/dominos.py b/homeassistant/components/dominos.py new file mode 100644 index 0000000000000..2c9f763aaa843 --- /dev/null +++ b/homeassistant/components/dominos.py @@ -0,0 +1,252 @@ +""" +Support for Dominos Pizza ordering. + +The Dominos Pizza component creates a service which can be invoked to order +from their menu + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/dominos/. +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components import http +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +# The domain of your component. Should be equal to the name of your component. +DOMAIN = 'dominos' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +ATTR_COUNTRY = 'country_code' +ATTR_FIRST_NAME = 'first_name' +ATTR_LAST_NAME = 'last_name' +ATTR_EMAIL = 'email' +ATTR_PHONE = 'phone' +ATTR_ADDRESS = 'address' +ATTR_ORDERS = 'orders' +ATTR_SHOW_MENU = 'show_menu' +ATTR_ORDER_ENTITY = 'order_entity_id' +ATTR_ORDER_NAME = 'name' +ATTR_ORDER_CODES = 'codes' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) +MIN_TIME_BETWEEN_STORE_UPDATES = timedelta(minutes=3330) + +REQUIREMENTS = ['pizzapi==0.0.3'] + +DEPENDENCIES = ['http'] + +_ORDERS_SCHEMA = vol.Schema({ + vol.Required(ATTR_ORDER_NAME): cv.string, + vol.Required(ATTR_ORDER_CODES): vol.All(cv.ensure_list, [cv.string]), +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(ATTR_COUNTRY): cv.string, + vol.Required(ATTR_FIRST_NAME): cv.string, + vol.Required(ATTR_LAST_NAME): cv.string, + vol.Required(ATTR_EMAIL): cv.string, + vol.Required(ATTR_PHONE): cv.string, + vol.Required(ATTR_ADDRESS): cv.string, + vol.Optional(ATTR_SHOW_MENU): cv.boolean, + vol.Optional(ATTR_ORDERS, default=[]): vol.All( + cv.ensure_list, [_ORDERS_SCHEMA]), + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up is called when Home Assistant is loading our component.""" + dominos = Dominos(hass, config) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + hass.data[DOMAIN] = {} + entities = [] + conf = config[DOMAIN] + + hass.services.register(DOMAIN, 'order', dominos.handle_order) + + if conf.get(ATTR_SHOW_MENU): + hass.http.register_view(DominosProductListView(dominos)) + + for order_info in conf.get(ATTR_ORDERS): + order = DominosOrder(order_info, dominos) + entities.append(order) + + if entities: + component.add_entities(entities) + + # Return boolean to indicate that initialization was successfully. + return True + + +class Dominos(): + """Main Dominos service.""" + + def __init__(self, hass, config): + """Set up main service.""" + conf = config[DOMAIN] + from pizzapi import Address, Customer + from pizzapi.address import StoreException + self.hass = hass + self.customer = Customer( + conf.get(ATTR_FIRST_NAME), + conf.get(ATTR_LAST_NAME), + conf.get(ATTR_EMAIL), + conf.get(ATTR_PHONE), + conf.get(ATTR_ADDRESS)) + self.address = Address( + *self.customer.address.split(','), + country=conf.get(ATTR_COUNTRY)) + self.country = conf.get(ATTR_COUNTRY) + try: + self.closest_store = self.address.closest_store() + except StoreException: + self.closest_store = None + + def handle_order(self, call): + """Handle ordering pizza.""" + entity_ids = call.data.get(ATTR_ORDER_ENTITY, None) + + target_orders = [order for order in self.hass.data[DOMAIN]['entities'] + if order.entity_id in entity_ids] + + for order in target_orders: + order.place() + + @Throttle(MIN_TIME_BETWEEN_STORE_UPDATES) + def update_closest_store(self): + """Update the shared closest store (if open).""" + from pizzapi.address import StoreException + try: + self.closest_store = self.address.closest_store() + return True + except StoreException: + self.closest_store = None + return False + + def get_menu(self): + """Return the products from the closest stores menu.""" + self.update_closest_store() + if self.closest_store is None: + _LOGGER.warning('Cannot get menu. Store may be closed') + return [] + menu = self.closest_store.get_menu() + product_entries = [] + + for product in menu.products: + item = {} + if isinstance(product.menu_data['Variants'], list): + variants = ', '.join(product.menu_data['Variants']) + else: + variants = product.menu_data['Variants'] + item['name'] = product.name + item['variants'] = variants + product_entries.append(item) + + return product_entries + + +class DominosProductListView(http.HomeAssistantView): + """View to retrieve product list content.""" + + url = '/api/dominos' + name = "api:dominos" + + def __init__(self, dominos): + """Initialize suite view.""" + self.dominos = dominos + + @callback + def get(self, request): + """Retrieve if API is running.""" + return self.json(self.dominos.get_menu()) + + +class DominosOrder(Entity): + """Represents a Dominos order entity.""" + + def __init__(self, order_info, dominos): + """Set up the entity.""" + self._name = order_info['name'] + self._product_codes = order_info['codes'] + self._orderable = False + self.dominos = dominos + + @property + def name(self): + """Return the orders name.""" + return self._name + + @property + def product_codes(self): + """Return the orders product codes.""" + return self._product_codes + + @property + def orderable(self): + """Return the true if orderable.""" + return self._orderable + + @property + def state(self): + """Return the state either closed, orderable or unorderable.""" + if self.dominos.closest_store is None: + return 'closed' + return 'orderable' if self._orderable else 'unorderable' + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update the order state and refreshes the store.""" + from pizzapi.address import StoreException + try: + self.dominos.update_closest_store() + except StoreException: + self._orderable = False + return + + try: + order = self.order() + order.pay_with() + self._orderable = True + except StoreException: + self._orderable = False + + def order(self): + """Create the order object.""" + from pizzapi import Order + from pizzapi.address import StoreException + + if self.dominos.closest_store is None: + raise StoreException + + order = Order( + self.dominos.closest_store, + self.dominos.customer, + self.dominos.address, + self.dominos.country) + + for code in self._product_codes: + order.add_item(code) + + return order + + def place(self): + """Place the order.""" + from pizzapi.address import StoreException + try: + order = self.order() + order.place() + except StoreException: + self._orderable = False + _LOGGER.warning( + 'Attempted to order Dominos - Order invalid or store closed') diff --git a/homeassistant/components/doorbird.py b/homeassistant/components/doorbird.py new file mode 100644 index 0000000000000..48f229b49cad8 --- /dev/null +++ b/homeassistant/components/doorbird.py @@ -0,0 +1,97 @@ +""" +Support for DoorBird device. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/doorbird/ +""" +import asyncio +import logging + +import voluptuous as vol + +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.3'] + +_LOGGER = logging.getLogger(__name__) + +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({ + vol.Required(CONF_HOST): cv.string, + 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) + +SENSOR_DOORBELL = 'doorbell' + + +def setup(hass, config): + """Set up the DoorBird component.""" + from doorbirdpy import DoorBird + + device_ip = config[DOMAIN].get(CONF_HOST) + username = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + + device = DoorBird(device_ip, username, password) + status = device.ready() + + if status[0]: + _LOGGER.info("Connected to DoorBird at %s as %s", device_ip, username) + hass.data[DOMAIN] = device + elif status[1] == 401: + _LOGGER.error("Authorization rejected by DoorBird at %s", device_ip) + return False + else: + _LOGGER.error("Could not connect to DoorBird at %s: Error %s", + device_ip, str(status[1])) + return False + + if config[DOMAIN].get(CONF_DOORBELL_EVENTS): + # 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_url, API_URL, SENSOR_DOORBELL) + device.reset_notifications() + device.subscribe_notification(SENSOR_DOORBELL, url) + + return True + + +class DoorbirdRequestView(HomeAssistantView): + """Provide a page for the device to call.""" + + requires_auth = False + url = API_URL + name = API_URL[1:].replace('/', ':') + extra_urls = [API_URL + '/{sensor}'] + + # pylint: disable=no-self-use + @asyncio.coroutine + def get(self, request, sensor): + """Respond to requests from the device.""" + hass = request.app['hass'] + hass.bus.async_fire('{}_{}'.format(DOMAIN, sensor)) + return 'OK' diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index 2e26b306673bc..0d57740a83d0e 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -17,18 +17,24 @@ _LOGGER = logging.getLogger(__name__) +ATTR_FILENAME = 'filename' ATTR_SUBDIR = 'subdir' ATTR_URL = 'url' +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' SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema({ vol.Required(ATTR_URL): cv.url, vol.Optional(ATTR_SUBDIR): cv.string, + vol.Optional(ATTR_FILENAME): cv.string, + vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean, }) CONFIG_SCHEMA = vol.Schema({ @@ -62,6 +68,10 @@ def do_download(): subdir = service.data.get(ATTR_SUBDIR) + filename = service.data.get(ATTR_FILENAME) + + overwrite = service.data.get(ATTR_OVERWRITE) + if subdir: subdir = sanitize_filename(subdir) @@ -69,10 +79,15 @@ def do_download(): req = requests.get(url, stream=True, timeout=10) - if req.status_code == 200: - filename = None + if req.status_code != 200: + _LOGGER.warning( + "downloading '%s' failed, status_code=%d", + url, + req.status_code) - if 'content-disposition' in req.headers: + else: + if filename is None and \ + 'content-disposition' in req.headers: match = re.findall(r"filename=(\S+)", req.headers['content-disposition']) @@ -80,8 +95,7 @@ def do_download(): filename = match[0].strip("'\" ") if not filename: - filename = os.path.basename( - url).strip() + filename = os.path.basename(url).strip() if not filename: filename = 'ha_download' @@ -106,23 +120,34 @@ def do_download(): # If file exist append a number. # We test filename, filename_2.. - tries = 1 - final_path = path + ext - while os.path.isfile(final_path): - tries += 1 + if not overwrite: + tries = 1 + final_path = path + ext + while os.path.isfile(final_path): + tries += 1 - final_path = "{}_{}.{}".format(path, tries, ext) + final_path = "{}_{}.{}".format(path, tries, ext) - _LOGGER.info("%s -> %s", url, final_path) + _LOGGER.debug("%s -> %s", url, final_path) with open(final_path, 'wb') as fil: for chunk in req.iter_content(1024): fil.write(chunk) - _LOGGER.info("Downloading of %s done", url) + _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 occured for %s", url) + _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/duckdns.py b/homeassistant/components/duckdns.py new file mode 100644 index 0000000000000..178e1579538aa --- /dev/null +++ b/homeassistant/components/duckdns.py @@ -0,0 +1,112 @@ +""" +Integrate with DuckDNS. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/duckdns/ +""" +import asyncio +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN +from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + +ATTR_TXT = 'txt' + +DOMAIN = 'duckdns' + +INTERVAL = timedelta(minutes=5) + +SERVICE_SET_TXT = 'set_txt' + +UPDATE_URL = 'https://www.duckdns.org/update' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +SERVICE_TXT_SCHEMA = vol.Schema({ + vol.Required(ATTR_TXT): vol.Any(None, cv.string) +}) + + +@bind_hass +@asyncio.coroutine +def async_set_txt(hass, txt): + """Set the txt record. Pass in None to remove it.""" + yield from hass.services.async_call(DOMAIN, SERVICE_SET_TXT, { + ATTR_TXT: txt + }, blocking=True) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the DuckDNS component.""" + domain = config[DOMAIN][CONF_DOMAIN] + token = config[DOMAIN][CONF_ACCESS_TOKEN] + session = async_get_clientsession(hass) + + result = yield from _update_duckdns(session, domain, token) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the DuckDNS entry.""" + yield from _update_duckdns(session, domain, token) + + @asyncio.coroutine + def update_domain_service(call): + """Update the DuckDNS entry.""" + yield from _update_duckdns( + session, domain, token, txt=call.data[ATTR_TXT]) + + async_track_time_interval(hass, update_domain_interval, INTERVAL) + hass.services.async_register( + DOMAIN, SERVICE_SET_TXT, update_domain_service, + schema=SERVICE_TXT_SCHEMA) + + return result + + +_SENTINEL = object() + + +@asyncio.coroutine +def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False): + """Update DuckDNS.""" + params = { + 'domains': domain, + 'token': token, + } + + if txt is not _SENTINEL: + if txt is None: + # Pass in empty txt value to indicate it's clearing txt record + params['txt'] = '' + clear = True + else: + params['txt'] = txt + + if clear: + params['clear'] = 'true' + + resp = yield from session.get(UPDATE_URL, params=params) + body = yield from resp.text() + + if body != 'OK': + _LOGGER.warning("Updating DuckDNS domain failed: %s", domain) + return False + + return True diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index c4b0f2e9546e4..9c29cea704c32 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -14,8 +14,9 @@ from homeassistant.helpers import discovery 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.9'] +REQUIREMENTS = ['python-ecobee-api==0.0.18'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -81,6 +82,7 @@ def setup_ecobee(hass, network, config): hass, 'climate', DOMAIN, {'hold_temp': hold_temp}, config) discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) + discovery.load_platform(hass, 'weather', DOMAIN, {}, config) class EcobeeData(object): @@ -110,12 +112,10 @@ def setup(hass, config): if 'ecobee' in _CONFIGURING: return - from pyecobee import config_from_file - # Create ecobee.conf if it doesn't exist if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)): jsonconfig = {"API_KEY": config[DOMAIN].get(CONF_API_KEY)} - config_from_file(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig) + save_json(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig) NETWORK = EcobeeData(hass.config.path(ECOBEE_CONFIG_FILE)) diff --git a/homeassistant/components/egardia.py b/homeassistant/components/egardia.py new file mode 100644 index 0000000000000..f350ea56bb4a5 --- /dev/null +++ b/homeassistant/components/egardia.py @@ -0,0 +1,123 @@ +""" +Interfaces with Egardia/Woonveilig alarm control panel. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/egardia/ +""" +import logging + +import requests +import voluptuous as vol + +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_PORT, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, + EVENT_HOMEASSISTANT_STOP) + +REQUIREMENTS = ['pythonegardia==1.0.39'] + +_LOGGER = logging.getLogger(__name__) + +CONF_REPORT_SERVER_CODES = 'report_server_codes' +CONF_REPORT_SERVER_ENABLED = 'report_server_enabled' +CONF_REPORT_SERVER_PORT = 'report_server_port' +REPORT_SERVER_CODES_IGNORE = 'ignore' +CONF_VERSION = 'version' + +DEFAULT_NAME = 'Egardia' +DEFAULT_PORT = 80 +DEFAULT_REPORT_SERVER_ENABLED = False +DEFAULT_REPORT_SERVER_PORT = 52010 +DEFAULT_VERSION = 'GATE-01' +DOMAIN = 'egardia' +EGARDIA_SERVER = 'egardia_server' +EGARDIA_DEVICE = 'egardiadevice' +EGARDIA_NAME = 'egardianame' +EGARDIA_REPORT_SERVER_ENABLED = 'egardia_rs_enabled' +EGARDIA_REPORT_SERVER_CODES = 'egardia_rs_codes' +NOTIFICATION_ID = 'egardia_notification' +NOTIFICATION_TITLE = 'Egardia' +ATTR_DISCOVER_DEVICES = 'egardia_sensor' + +SERVER_CODE_SCHEMA = vol.Schema({ + vol.Optional('arm'): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional('disarm'): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional('armhome'): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional('triggered'): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional('ignore'): vol.All(cv.ensure_list_csv, [cv.string]) +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_REPORT_SERVER_CODES, default={}): SERVER_CODE_SCHEMA, + vol.Optional(CONF_REPORT_SERVER_ENABLED, + default=DEFAULT_REPORT_SERVER_ENABLED): cv.boolean, + vol.Optional(CONF_REPORT_SERVER_PORT, + default=DEFAULT_REPORT_SERVER_PORT): cv.port, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Egardia platform.""" + from pythonegardia import egardiadevice + from pythonegardia import egardiaserver + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + version = conf.get(CONF_VERSION) + rs_enabled = conf.get(CONF_REPORT_SERVER_ENABLED) + rs_port = conf.get(CONF_REPORT_SERVER_PORT) + try: + device = hass.data[EGARDIA_DEVICE] = egardiadevice.EgardiaDevice( + host, port, username, password, '', version) + except requests.exceptions.RequestException: + _LOGGER.error("An error occurred accessing your Egardia device. " + + "Please check config.") + return False + except egardiadevice.UnauthorizedError: + _LOGGER.error("Unable to authorize. Wrong password or username.") + return False + # Set up the egardia server if enabled + if rs_enabled: + _LOGGER.debug("Setting up EgardiaServer") + try: + if EGARDIA_SERVER not in hass.data: + server = egardiaserver.EgardiaServer('', rs_port) + bound = server.bind() + if not bound: + raise IOError("Binding error occurred while " + + "starting EgardiaServer.") + hass.data[EGARDIA_SERVER] = server + server.start() + + def handle_stop_event(event): + """Callback function for HA stop event.""" + server.stop() + + # listen to home assistant stop event + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop_event) + + except IOError: + _LOGGER.error("Binding error occurred while starting " + + "EgardiaServer.") + return False + + discovery.load_platform(hass, 'alarm_control_panel', DOMAIN, + discovered=conf, hass_config=config) + + # get the sensors from the device and add those + sensors = device.getsensors() + discovery.load_platform(hass, 'binary_sensor', DOMAIN, + {ATTR_DISCOVER_DEVICES: sensors}, config) + + return True diff --git a/homeassistant/components/eight_sleep.py b/homeassistant/components/eight_sleep.py index 40a5d884aed01..3478d5cd08e7e 100644 --- a/homeassistant/components/eight_sleep.py +++ b/homeassistant/components/eight_sleep.py @@ -6,13 +6,11 @@ """ import asyncio import logging -import os from datetime import timedelta import voluptuous as vol from homeassistant.core import callback -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( CONF_USERNAME, CONF_PASSWORD, CONF_SENSORS, CONF_BINARY_SENSORS, ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP) @@ -24,7 +22,7 @@ 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__) @@ -159,10 +157,6 @@ def async_update_user_data(now): CONF_BINARY_SENSORS: binary_sensors, }, config)) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine def async_service_handler(service): """Handle eight sleep service calls.""" @@ -183,7 +177,6 @@ def async_service_handler(service): # Register services hass.services.async_register( DOMAIN, SERVICE_HEAT_SET, async_service_handler, - descriptions[DOMAIN].get(SERVICE_HEAT_SET), schema=SERVICE_EIGHT_SCHEMA) @asyncio.coroutine @@ -200,7 +193,7 @@ class EightSleepUserEntity(Entity): """The Eight Sleep device entity.""" def __init__(self, eight): - """Initialize the data oject.""" + """Initialize the data object.""" self._eight = eight @asyncio.coroutine @@ -209,7 +202,7 @@ def async_added_to_hass(self): @callback def async_eight_user_update(): """Update callback.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_USER, async_eight_user_update) @@ -224,7 +217,7 @@ class EightSleepHeatEntity(Entity): """The Eight Sleep device entity.""" def __init__(self, eight): - """Initialize the data oject.""" + """Initialize the data object.""" self._eight = eight @asyncio.coroutine @@ -233,7 +226,7 @@ def async_added_to_hass(self): @callback def async_eight_heat_update(): """Update callback.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update) diff --git a/homeassistant/components/emoncms_history.py b/homeassistant/components/emoncms_history.py index 34d9fd0f4586c..6a92ab6404443 100644 --- a/homeassistant/components/emoncms_history.py +++ b/homeassistant/components/emoncms_history.py @@ -59,7 +59,7 @@ def send_data(url, apikey, node, payload): payload, fullurl, req.status_code) def update_emoncms(time): - """Send whitelisted entities states reguarly to Emoncms.""" + """Send whitelisted entities states regularly to Emoncms.""" payload_dict = {} for entity_id in whitelist: diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index ae0a26aaea4ae..fd7f7147fdba0 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -4,8 +4,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/emulated_hue/ """ -import asyncio -import json import logging import voluptuous as vol @@ -15,11 +13,14 @@ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.components.http import REQUIREMENTS # NOQA -from homeassistant.components.http import HomeAssistantWSGI +from homeassistant.components.http import HomeAssistantHTTP +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.deprecation import get_deprecated 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' @@ -37,6 +38,9 @@ CONF_EXPOSE_BY_DEFAULT = 'expose_by_default' CONF_EXPOSED_DOMAINS = 'exposed_domains' CONF_TYPE = 'type' +CONF_ENTITIES = 'entities' +CONF_ENTITY_NAME = 'name' +CONF_ENTITY_HIDDEN = 'hidden' TYPE_ALEXA = 'alexa' TYPE_GOOGLE = 'google_home' @@ -50,6 +54,11 @@ ] DEFAULT_TYPE = TYPE_GOOGLE +CONFIG_ENTITY_SCHEMA = vol.Schema({ + vol.Optional(CONF_ENTITY_NAME): cv.string, + vol.Optional(CONF_ENTITY_HIDDEN): cv.boolean +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_HOST_IP): cv.string, @@ -61,20 +70,23 @@ vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean, vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list, vol.Optional(CONF_TYPE, default=DEFAULT_TYPE): - vol.Any(TYPE_ALEXA, TYPE_GOOGLE) + vol.Any(TYPE_ALEXA, TYPE_GOOGLE), + vol.Optional(CONF_ENTITIES): + vol.Schema({cv.entity_id: CONFIG_ENTITY_SCHEMA}) }) }, extra=vol.ALLOW_EXTRA) ATTR_EMULATED_HUE = 'emulated_hue' +ATTR_EMULATED_HUE_NAME = 'emulated_hue_name' +ATTR_EMULATED_HUE_HIDDEN = 'emulated_hue_hidden' def setup(hass, yaml_config): """Activate the emulated_hue component.""" config = Config(hass, yaml_config.get(DOMAIN, {})) - server = HomeAssistantWSGI( + server = HomeAssistantHTTP( hass, - development=False, server_host=config.host_ip_addr, server_port=config.listen_port, api_password=None, @@ -92,23 +104,22 @@ 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, config.upnp_bind_multicast, config.advertise_ip, config.advertise_port) - @asyncio.coroutine - def stop_emulated_hue_bridge(event): + async def stop_emulated_hue_bridge(event): """Stop the emulated hue bridge.""" upnp_listener.stop() - yield from server.stop() + await server.stop() - @asyncio.coroutine - def start_emulated_hue_bridge(event): + async def start_emulated_hue_bridge(event): """Start the emulated hue bridge.""" upnp_listener.start() - yield from server.start() + await server.start() hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) @@ -128,14 +139,15 @@ def __init__(self, hass, conf): self.cached_states = {} if self.type == TYPE_ALEXA: - _LOGGER.warning("Alexa type is deprecated and will be removed in a" - "future version") + _LOGGER.warning( + 'Emulated Hue running in legacy mode because type has been ' + 'specified. More info at https://goo.gl/M6tgz8') # Get the IP address that will be passed to the Echo during discovery self.host_ip_addr = conf.get(CONF_HOST_IP) if self.host_ip_addr is None: self.host_ip_addr = util.get_local_ip() - _LOGGER.warning( + _LOGGER.info( "Listen IP address not specified, auto-detected address is %s", self.host_ip_addr) @@ -143,14 +155,10 @@ def __init__(self, hass, conf): self.listen_port = conf.get(CONF_LISTEN_PORT) if not isinstance(self.listen_port, int): self.listen_port = DEFAULT_LISTEN_PORT - _LOGGER.warning( + _LOGGER.info( "Listen port not specified, defaulting to %s", self.listen_port) - if self.type == TYPE_GOOGLE and self.listen_port != 80: - _LOGGER.warning("When targetting Google Home, listening port has " - "to be port 80") - # Get 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( @@ -180,13 +188,15 @@ def __init__(self, hass, conf): self.advertise_port = conf.get( CONF_ADVERTISE_PORT) or self.listen_port + self.entities = conf.get(CONF_ENTITIES, {}) + def entity_id_to_number(self, entity_id): """Get a unique number for the entity id.""" if self.type == TYPE_ALEXA: return entity_id if self.numbers is None: - self.numbers = self._load_numbers_json() + self.numbers = _load_json(self.hass.config.path(NUMBERS_FILE)) # Google Home for number, ent_id in self.numbers.items(): @@ -197,7 +207,7 @@ def entity_id_to_number(self, entity_id): if self.numbers: number = str(max(int(k) for k in self.numbers) + 1) self.numbers[number] = entity_id - self._save_numbers_json() + save_json(self.hass.config.path(NUMBERS_FILE), self.numbers) return number def number_to_entity_id(self, number): @@ -206,12 +216,20 @@ def number_to_entity_id(self, number): return number if self.numbers is None: - self.numbers = self._load_numbers_json() + self.numbers = _load_json(self.hass.config.path(NUMBERS_FILE)) # Google Home assert isinstance(number, str) return self.numbers.get(number) + def get_entity_name(self, entity): + """Get the name of an entity.""" + if entity.entity_id in self.entities and \ + CONF_ENTITY_NAME in self.entities[entity.entity_id]: + return self.entities[entity.entity_id][CONF_ENTITY_NAME] + + return entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name) + def is_entity_exposed(self, entity): """Determine if an entity should be exposed on the emulated bridge. @@ -223,7 +241,21 @@ def is_entity_exposed(self, entity): domain = entity.domain.lower() explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None) - + explicit_hidden = entity.attributes.get(ATTR_EMULATED_HUE_HIDDEN, None) + + if entity.entity_id in self.entities and \ + CONF_ENTITY_HIDDEN in self.entities[entity.entity_id]: + explicit_hidden = \ + self.entities[entity.entity_id][CONF_ENTITY_HIDDEN] + + if explicit_expose is True or explicit_hidden is False: + expose = True + elif explicit_expose is False or explicit_hidden is True: + expose = False + else: + expose = None + get_deprecated(entity.attributes, ATTR_EMULATED_HUE_HIDDEN, + ATTR_EMULATED_HUE, None) domain_exposed_by_default = \ self.expose_by_default and domain in self.exposed_domains @@ -231,29 +263,15 @@ def is_entity_exposed(self, entity): # the configuration doesn't explicitly exclude it from being # exposed, or if the entity is explicitly exposed is_default_exposed = \ - domain_exposed_by_default and explicit_expose is not False - - return is_default_exposed or explicit_expose - - def _load_numbers_json(self): - """Set up helper method to load numbers json.""" - try: - with open(self.hass.config.path(NUMBERS_FILE), - encoding='utf-8') as fil: - return json.loads(fil.read()) - except (OSError, ValueError) as err: - # OSError if file not found or unaccessible/no permissions - # ValueError if could not parse JSON - if not isinstance(err, FileNotFoundError): - _LOGGER.warning("Failed to open %s: %s", NUMBERS_FILE, err) - return {} - - def _save_numbers_json(self): - """Set up helper method to save numbers json.""" - try: - with open(self.hass.config.path(NUMBERS_FILE), 'w', - encoding='utf-8') as fil: - fil.write(json.dumps(self.numbers)) - except OSError as err: - # OSError if file write permissions - _LOGGER.warning("Failed to write %s: %s", NUMBERS_FILE, err) + domain_exposed_by_default and expose is not False + + return is_default_exposed or expose + + +def _load_json(filename): + """Wrapper, because we actually want to handle invalid json.""" + try: + return load_json(filename) + except HomeAssistantError: + pass + return {} diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 7ff174b32b6dc..2b74984e4ca2e 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -24,9 +24,6 @@ _LOGGER = logging.getLogger(__name__) -ATTR_EMULATED_HUE = 'emulated_hue' -ATTR_EMULATED_HUE_NAME = 'emulated_hue_name' - HUE_API_STATE_ON = 'on' HUE_API_STATE_BRI = 'bri' @@ -54,6 +51,29 @@ def post(self, request): 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.""" @@ -77,7 +97,7 @@ def get(self, request, username): number = self.config.entity_id_to_number(entity.entity_id) json_response[number] = entity_to_json( - entity, state, brightness) + self.config, entity, state, brightness) return self.json(json_response) @@ -110,7 +130,7 @@ def get(self, request, username, entity_id): state, brightness = get_entity_state(self.config, entity) - json_response = entity_to_json(entity, state, brightness) + json_response = entity_to_json(self.config, entity, state, brightness) return self.json(json_response) @@ -287,6 +307,11 @@ def parse_hue_api_put_light_body(request_json, entity): report_brightness = True result = (brightness > 0) + elif entity.domain == "scene": + brightness = None + report_brightness = False + result = True + elif (entity.domain == "script" or entity.domain == "media_player" or entity.domain == "fan"): @@ -339,10 +364,8 @@ def get_entity_state(config, entity): return (final_state, final_brightness) -def entity_to_json(entity, is_on=None, brightness=None): +def entity_to_json(config, entity, is_on=None, brightness=None): """Convert an entity to its Hue bridge JSON representation.""" - name = entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name) - return { 'state': { @@ -351,7 +374,7 @@ def entity_to_json(entity, is_on=None, brightness=None): 'reachable': True }, 'type': 'Dimmable light', - 'name': name, + 'name': config.get_entity_name(entity), 'modelid': 'HASS123', 'uniqueid': entity.entity_id, 'swversion': '123' diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index f8d414240649c..548b6f3d77197 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -1,4 +1,4 @@ -"""Provides a UPNP discovery method that mimicks Hue hubs.""" +"""Provides a UPNP discovery method that mimics Hue hubs.""" import threading import socket import logging @@ -123,20 +123,20 @@ def run(self): if ssdp_socket in read: data, addr = ssdp_socket.recvfrom(1024) else: - # most likely the timeout, so check for interupt + # most likely the timeout, so check for interrupt continue except socket.error as ex: if self._interrupted: clean_socket_close(ssdp_socket) return - _LOGGER.error("UPNP Responder socket exception occured: %s", + _LOGGER.error("UPNP Responder socket exception occurred: %s", ex.__str__) # without the following continue, a second exception occurs # because the data object has not been initialized continue - if "M-SEARCH" in data.decode('utf-8'): + if "M-SEARCH" in data.decode('utf-8', errors='ignore'): # SSDP M-SEARCH method received, respond to it with our info resp_socket = socket.socket( socket.AF_INET, socket.SOCK_DGRAM) diff --git a/homeassistant/components/enocean.py b/homeassistant/components/enocean.py index 79c2e3dce8d3e..879f6a6189980 100644 --- a/homeassistant/components/enocean.py +++ b/homeassistant/components/enocean.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_DEVICE import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['enocean==0.31'] +REQUIREMENTS = ['enocean==0.40'] _LOGGER = logging.getLogger(__name__) @@ -72,6 +72,7 @@ def callback(self, temp): """ from enocean.protocol.packet import RadioPacket if isinstance(temp, RadioPacket): + _LOGGER.debug("Received radio packet: %s", temp) rxtype = None value = None if temp.data[6] == 0x30: @@ -94,20 +95,20 @@ def callback(self, temp): value = temp.data[2] for device in self.__devices: if rxtype == "wallswitch" and device.stype == "listener": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value, temp.data[1]) if rxtype == "power" and device.stype == "powersensor": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value) if rxtype == "power" and device.stype == "switch": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): if value > 10: device.value_changed(1) if rxtype == "switch_status" and device.stype == "switch": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value) if rxtype == "dimmerstatus" and device.stype == "dimmer": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value) diff --git a/homeassistant/components/eufy.py b/homeassistant/components/eufy.py new file mode 100644 index 0000000000000..892c0b9972adf --- /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.6'] + +_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/__init__.py b/homeassistant/components/fan/__init__.py index fd12529cb486d..66790d0268729 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -8,12 +8,10 @@ from datetime import timedelta import functools as ft import logging -import os import voluptuous as vol from homeassistant.components import group -from homeassistant.config import load_yaml_config_file from homeassistant.const import (SERVICE_TURN_ON, SERVICE_TOGGLE, SERVICE_TURN_OFF, ATTR_ENTITY_ID, STATE_UNKNOWN) @@ -120,7 +118,7 @@ @bind_hass -def is_on(hass, entity_id: str=None) -> bool: +def is_on(hass, entity_id: str = None) -> bool: """Return if the fans are on based on the statemachine.""" entity_id = entity_id or ENTITY_ID_ALL_FANS state = hass.states.get(entity_id) @@ -128,7 +126,7 @@ def is_on(hass, entity_id: str=None) -> bool: @bind_hass -def turn_on(hass, entity_id: str=None, speed: str=None) -> None: +def turn_on(hass, entity_id: str = None, speed: str = None) -> None: """Turn all or specified fan on.""" data = { key: value for key, value in [ @@ -141,7 +139,7 @@ def turn_on(hass, entity_id: str=None, speed: str=None) -> None: @bind_hass -def turn_off(hass, entity_id: str=None) -> None: +def turn_off(hass, entity_id: str = None) -> None: """Turn all or specified fan off.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} @@ -149,7 +147,7 @@ def turn_off(hass, entity_id: str=None) -> None: @bind_hass -def toggle(hass, entity_id: str=None) -> None: +def toggle(hass, entity_id: str = None) -> None: """Toggle all or specified fans.""" data = { ATTR_ENTITY_ID: entity_id @@ -159,7 +157,8 @@ def toggle(hass, entity_id: str=None) -> None: @bind_hass -def oscillate(hass, entity_id: str=None, should_oscillate: bool=True) -> None: +def oscillate(hass, entity_id: str = None, + should_oscillate: bool = True) -> None: """Set oscillation on all or specified fan.""" data = { key: value for key, value in [ @@ -172,7 +171,7 @@ def oscillate(hass, entity_id: str=None, should_oscillate: bool=True) -> None: @bind_hass -def set_speed(hass, entity_id: str=None, speed: str=None) -> None: +def set_speed(hass, entity_id: str = None, speed: str = None) -> None: """Set speed for all or specified fan.""" data = { key: value for key, value in [ @@ -185,7 +184,7 @@ def set_speed(hass, entity_id: str=None, speed: str=None) -> None: @bind_hass -def set_direction(hass, entity_id: str=None, direction: str=None) -> None: +def set_direction(hass, entity_id: str = None, direction: str = None) -> None: """Set direction for all or specified fan.""" data = { key: value for key, value in [ @@ -207,7 +206,7 @@ def async_setup(hass, config: dict): @asyncio.coroutine def async_handle_fan_service(service): - """Hande service call for fans.""" + """Handle service call for fans.""" method = SERVICE_TO_METHOD.get(service.service) params = service.data.copy() @@ -215,34 +214,20 @@ def async_handle_fan_service(service): target_fans = component.async_extract_from_service(service) params.pop(ATTR_ENTITY_ID, None) - for fan in target_fans: - yield from getattr(fan, method['method'])(**params) - update_tasks = [] - for fan in target_fans: + yield from getattr(fan, method['method'])(**params) if not fan.should_poll: continue - - update_coro = hass.async_add_job(fan.async_update_ha_state(True)) - if hasattr(fan, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(fan.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - # Listen for fan service calls. - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - for service_name in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service_name].get('schema') hass.services.async_register( - DOMAIN, service_name, async_handle_fan_service, - descriptions.get(service_name), schema=schema) + DOMAIN, service_name, async_handle_fan_service, schema=schema) return True @@ -274,11 +259,13 @@ def async_set_direction(self: ToggleEntity, direction: str): """ return self.hass.async_add_job(self.set_direction, direction) - def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + # pylint: disable=arguments-differ + def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn on the fan.""" raise NotImplementedError() - def async_turn_on(self: ToggleEntity, speed: str=None, **kwargs): + # pylint: disable=arguments-differ + def async_turn_on(self: ToggleEntity, speed: str = None, **kwargs): """Turn on the fan. This method must be run in the event loop and returns a coroutine. diff --git a/homeassistant/components/fan/comfoconnect.py b/homeassistant/components/fan/comfoconnect.py index ab32e588c0379..12dc0b1104f29 100644 --- a/homeassistant/components/fan/comfoconnect.py +++ b/homeassistant/components/fan/comfoconnect.py @@ -37,7 +37,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class ComfoConnectFan(FanEntity): """Representation of the ComfoConnect fan platform.""" - def __init__(self, hass, name, ccb: ComfoConnectBridge): + def __init__(self, hass, name, ccb: ComfoConnectBridge) -> None: """Initialize the ComfoConnect fan.""" from pycomfoconnect import SENSOR_FAN_SPEED_MODE @@ -87,31 +87,31 @@ def speed_list(self): """List of available fan modes.""" return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def turn_on(self, speed: str=None, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the fan.""" if speed is None: speed = SPEED_LOW self.set_speed(speed) - def turn_off(self) -> None: + def turn_off(self, **kwargs) -> None: """Turn off the fan (to away).""" self.set_speed(SPEED_OFF) - def set_speed(self, mode): + def set_speed(self, speed: str): """Set fan speed.""" - _LOGGER.debug('Changing fan mode to %s.', mode) + _LOGGER.debug('Changing fan speed to %s.', speed) from pycomfoconnect import ( CMD_FAN_MODE_AWAY, CMD_FAN_MODE_LOW, CMD_FAN_MODE_MEDIUM, CMD_FAN_MODE_HIGH) - if mode == SPEED_OFF: + if speed == SPEED_OFF: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_AWAY) - elif mode == SPEED_LOW: + elif speed == SPEED_LOW: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_LOW) - elif mode == SPEED_MEDIUM: + elif speed == SPEED_MEDIUM: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_MEDIUM) - elif mode == SPEED_HIGH: + elif speed == SPEED_HIGH: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_HIGH) # Update current mode diff --git a/homeassistant/components/fan/demo.py b/homeassistant/components/fan/demo.py index bdb1b784c8bea..b328ebb310174 100644 --- a/homeassistant/components/fan/demo.py +++ b/homeassistant/components/fan/demo.py @@ -59,13 +59,13 @@ def speed_list(self) -> list: """Get the list of available speeds.""" return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def turn_on(self, speed: str=None) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the entity.""" if speed is None: speed = SPEED_MEDIUM self.set_speed(speed) - def turn_off(self) -> None: + def turn_off(self, **kwargs) -> None: """Turn off the entity.""" self.oscillate(False) self.set_speed(STATE_OFF) diff --git a/homeassistant/components/fan/dyson.py b/homeassistant/components/fan/dyson.py index 0e0e3fdfaf3dd..5b689ece6ed20 100644 --- a/homeassistant/components/fan/dyson.py +++ b/homeassistant/components/fan/dyson.py @@ -3,40 +3,42 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/fan.dyson/ """ -import logging import asyncio -from os import path +import logging + import voluptuous as vol + +from homeassistant.components.dyson import DYSON_DEVICES +from homeassistant.components.fan import ( + DOMAIN, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity) +from homeassistant.const import CONF_ENTITY_ID import homeassistant.helpers.config_validation as cv -from homeassistant.components.fan import (FanEntity, SUPPORT_OSCILLATE, - SUPPORT_SET_SPEED, - DOMAIN) from homeassistant.helpers.entity import ToggleEntity -from homeassistant.components.dyson import DYSON_DEVICES -from homeassistant.config import load_yaml_config_file - -DEPENDENCIES = ['dyson'] _LOGGER = logging.getLogger(__name__) +CONF_NIGHT_MODE = 'night_mode' + +DEPENDENCIES = ['dyson'] +DYSON_FAN_DEVICES = 'dyson_fan_devices' -DYSON_FAN_DEVICES = "dyson_fan_devices" SERVICE_SET_NIGHT_MODE = 'dyson_set_night_mode' DYSON_SET_NIGHT_MODE_SCHEMA = vol.Schema({ - vol.Required('entity_id'): cv.entity_id, - vol.Required('night_mode'): cv.boolean + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_NIGHT_MODE): cv.boolean, }) def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Dyson fan components.""" - _LOGGER.info("Creating new Dyson fans") + """Set up the Dyson fan components.""" + from libpurecoollink.dyson_pure_cool_link import DysonPureCoolLink + + _LOGGER.debug("Creating new Dyson fans") if DYSON_FAN_DEVICES not in hass.data: hass.data[DYSON_FAN_DEVICES] = [] # Get Dyson Devices from parent component - from libpurecoollink.dyson_pure_cool_link import DysonPureCoolLink for device in [d for d in hass.data[DYSON_DEVICES] if isinstance(d, DysonPureCoolLink)]: dyson_entity = DysonPureCoolLinkDevice(hass, device) @@ -44,13 +46,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(hass.data[DYSON_FAN_DEVICES]) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - def service_handle(service): - """Handle dyson services.""" - entity_id = service.data.get('entity_id') - night_mode = service.data.get('night_mode') + """Handle the Dyson services.""" + entity_id = service.data.get(CONF_ENTITY_ID) + night_mode = service.data.get(CONF_NIGHT_MODE) fan_device = next([fan for fan in hass.data[DYSON_FAN_DEVICES] if fan.entity_id == entity_id].__iter__(), None) if fan_device is None: @@ -62,10 +61,9 @@ def service_handle(service): fan_device.night_mode(night_mode) # Register dyson service(s) - hass.services.register(DOMAIN, SERVICE_SET_NIGHT_MODE, - service_handle, - descriptions.get(SERVICE_SET_NIGHT_MODE), - schema=DYSON_SET_NIGHT_MODE_SCHEMA) + hass.services.register( + DOMAIN, SERVICE_SET_NIGHT_MODE, service_handle, + schema=DYSON_SET_NIGHT_MODE_SCHEMA) class DysonPureCoolLinkDevice(FanEntity): @@ -73,21 +71,22 @@ class DysonPureCoolLinkDevice(FanEntity): def __init__(self, hass, device): """Initialize the fan.""" - _LOGGER.info("Creating device %s", device.name) + _LOGGER.debug("Creating device %s", device.name) self.hass = hass self._device = device @asyncio.coroutine def async_added_to_hass(self): - """Callback when entity is added to hass.""" + """Call when entity is added to hass.""" self.hass.async_add_job( self._device.add_message_listener, self.on_message) def on_message(self, message): - """Called when new messages received from the fan.""" + """Call when new messages received from the fan.""" from libpurecoollink.dyson_pure_state import DysonPureCoolState + if isinstance(message, DysonPureCoolState): - _LOGGER.debug("Message received for fan device %s : %s", self.name, + _LOGGER.debug("Message received for fan device %s: %s", self.name, message) self.schedule_update_ha_state() @@ -103,41 +102,46 @@ def name(self): def set_speed(self: ToggleEntity, speed: str) -> None: """Set the speed of the fan. Never called ??.""" - _LOGGER.debug("Set fan speed to: " + speed) from libpurecoollink.const import FanSpeed, FanMode + + _LOGGER.debug("Set fan speed to: %s", speed) + if speed == FanSpeed.FAN_SPEED_AUTO.value: self._device.set_configuration(fan_mode=FanMode.AUTO) else: fan_speed = FanSpeed('{0:04d}'.format(int(speed))) - self._device.set_configuration(fan_mode=FanMode.FAN, - fan_speed=fan_speed) + self._device.set_configuration( + fan_mode=FanMode.FAN, fan_speed=fan_speed) - def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn on the fan.""" - _LOGGER.debug("Turn on fan %s with speed %s", self.name, speed) from libpurecoollink.const import FanSpeed, FanMode + + _LOGGER.debug("Turn on fan %s with speed %s", self.name, speed) if speed: if speed == FanSpeed.FAN_SPEED_AUTO.value: self._device.set_configuration(fan_mode=FanMode.AUTO) else: fan_speed = FanSpeed('{0:04d}'.format(int(speed))) - self._device.set_configuration(fan_mode=FanMode.FAN, - fan_speed=fan_speed) + self._device.set_configuration( + fan_mode=FanMode.FAN, fan_speed=fan_speed) else: # Speed not set, just turn on self._device.set_configuration(fan_mode=FanMode.FAN) def turn_off(self: ToggleEntity, **kwargs) -> None: """Turn off the fan.""" - _LOGGER.debug("Turn off fan %s", self.name) from libpurecoollink.const import FanMode + + _LOGGER.debug("Turn off fan %s", self.name) self._device.set_configuration(fan_mode=FanMode.OFF) def oscillate(self: ToggleEntity, oscillating: bool) -> None: """Turn on/off oscillating.""" + from libpurecoollink.const import Oscillation + _LOGGER.debug("Turn oscillation %s for device %s", oscillating, self.name) - from libpurecoollink.const import Oscillation if oscillating: self._device.set_configuration( @@ -161,8 +165,9 @@ def is_on(self): @property def speed(self) -> str: """Return the current speed.""" + from libpurecoollink.const import FanSpeed + if self._device.state: - from libpurecoollink.const import FanSpeed if self._device.state.speed == FanSpeed.FAN_SPEED_AUTO.value: return self._device.state.speed return int(self._device.state.speed) @@ -180,8 +185,9 @@ def is_night_mode(self): def night_mode(self: ToggleEntity, night_mode: bool) -> None: """Turn fan in night mode.""" - _LOGGER.debug("Set %s night mode %s", self.name, night_mode) from libpurecoollink.const import NightMode + + _LOGGER.debug("Set %s night mode %s", self.name, night_mode) if night_mode: self._device.set_configuration(night_mode=NightMode.NIGHT_MODE_ON) else: @@ -194,8 +200,9 @@ def is_auto_mode(self): def auto_mode(self: ToggleEntity, auto_mode: bool) -> None: """Turn fan in auto mode.""" - _LOGGER.debug("Set %s auto mode %s", self.name, auto_mode) from libpurecoollink.const import FanMode + + _LOGGER.debug("Set %s auto mode %s", self.name, auto_mode) if auto_mode: self._device.set_configuration(fan_mode=FanMode.AUTO) else: @@ -205,17 +212,20 @@ def auto_mode(self: ToggleEntity, auto_mode: bool) -> None: def speed_list(self: ToggleEntity) -> list: """Get the list of available speeds.""" from libpurecoollink.const import FanSpeed - supported_speeds = [FanSpeed.FAN_SPEED_AUTO.value, - int(FanSpeed.FAN_SPEED_1.value), - int(FanSpeed.FAN_SPEED_2.value), - int(FanSpeed.FAN_SPEED_3.value), - int(FanSpeed.FAN_SPEED_4.value), - int(FanSpeed.FAN_SPEED_5.value), - int(FanSpeed.FAN_SPEED_6.value), - int(FanSpeed.FAN_SPEED_7.value), - int(FanSpeed.FAN_SPEED_8.value), - int(FanSpeed.FAN_SPEED_9.value), - int(FanSpeed.FAN_SPEED_10.value)] + + supported_speeds = [ + FanSpeed.FAN_SPEED_AUTO.value, + int(FanSpeed.FAN_SPEED_1.value), + int(FanSpeed.FAN_SPEED_2.value), + int(FanSpeed.FAN_SPEED_3.value), + int(FanSpeed.FAN_SPEED_4.value), + int(FanSpeed.FAN_SPEED_5.value), + int(FanSpeed.FAN_SPEED_6.value), + int(FanSpeed.FAN_SPEED_7.value), + int(FanSpeed.FAN_SPEED_8.value), + int(FanSpeed.FAN_SPEED_9.value), + int(FanSpeed.FAN_SPEED_10.value), + ] return supported_speeds diff --git a/homeassistant/components/fan/insteon_local.py b/homeassistant/components/fan/insteon_local.py index 5bdfec084279a..b8a5c99add42c 100644 --- a/homeassistant/components/fan/insteon_local.py +++ b/homeassistant/components/fan/insteon_local.py @@ -4,9 +4,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/fan.insteon_local/ """ -import json import logging -import os from datetime import timedelta from homeassistant.components.fan import ( @@ -21,8 +19,6 @@ DEPENDENCIES = ['insteon_local'] DOMAIN = 'fan' -INSTEON_LOCAL_FANS_CONF = 'insteon_local_fans.conf' - MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) @@ -32,118 +28,39 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Insteon local fan platform.""" insteonhub = hass.data['insteon_local'] - - conf_fans = config_from_file(hass.config.path(INSTEON_LOCAL_FANS_CONF)) - if conf_fans: - for device_id in conf_fans: - setup_fan(device_id, conf_fans[device_id], insteonhub, hass, - add_devices) - - else: - linked = insteonhub.get_linked() - - for device_id in linked: - if (linked[device_id]['cat_type'] == 'dimmer' and - linked[device_id]['sku'] == '2475F' and - device_id not in conf_fans): - request_configuration(device_id, - insteonhub, - linked[device_id]['model_name'] + ' ' + - linked[device_id]['sku'], - hass, add_devices) - - -def request_configuration(device_id, insteonhub, model, hass, - add_devices_callback): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - # We got an error if this method is called while we are configuring - if device_id in _CONFIGURING: - configurator.notify_errors( - _CONFIGURING[device_id], 'Failed to register, please try again.') - + if discovery_info is None: return - def insteon_fan_config_callback(data): - """The actions to do when our configuration callback is called.""" - setup_fan(device_id, data.get('name'), insteonhub, hass, - add_devices_callback) - - _CONFIGURING[device_id] = configurator.request_config( - 'Insteon ' + model + ' addr: ' + device_id, - insteon_fan_config_callback, - description=('Enter a name for ' + model + ' Fan addr: ' + device_id), - entity_picture='/static/images/config_insteon.png', - submit_caption='Confirm', - fields=[{'id': 'name', 'name': 'Name', 'type': ''}] - ) - - -def setup_fan(device_id, name, insteonhub, hass, add_devices_callback): - """Set up the fan.""" - if device_id in _CONFIGURING: - request_id = _CONFIGURING.pop(device_id) - configurator = hass.components.configurator - configurator.request_done(request_id) - _LOGGER.info("Device configuration done!") - - conf_fans = config_from_file(hass.config.path(INSTEON_LOCAL_FANS_CONF)) - if device_id not in conf_fans: - conf_fans[device_id] = name - - if not config_from_file( - hass.config.path(INSTEON_LOCAL_FANS_CONF), - conf_fans): - _LOGGER.error("Failed to save configuration file") - - device = insteonhub.fan(device_id) - add_devices_callback([InsteonLocalFanDevice(device, name)]) - - -def config_from_file(filename, config=None): - """Small configuration file management function.""" - if config: - # We're writing configuration - try: - with open(filename, 'w') as fdesc: - fdesc.write(json.dumps(config)) - except IOError as error: - _LOGGER.error('Saving config file failed: %s', error) - return False - return True - else: - # We're reading config - if os.path.isfile(filename): - try: - with open(filename, 'r') as fdesc: - return json.loads(fdesc.read()) - except IOError as error: - _LOGGER.error("Reading configuration file failed: %s", error) - # This won't work yet - return False - else: - return {} + linked = discovery_info['linked'] + device_list = [] + for device_id in linked: + if (linked[device_id]['cat_type'] == 'dimmer' and + linked[device_id]['sku'] == '2475F'): + device = insteonhub.fan(device_id) + device_list.append( + InsteonLocalFanDevice(device) + ) + + add_devices(device_list) class InsteonLocalFanDevice(FanEntity): """An abstract Class for an Insteon node.""" - def __init__(self, node, name): + def __init__(self, node): """Initialize the device.""" self.node = node - self.node.deviceName = name self._speed = SPEED_OFF @property def name(self): - """Return the the name of the node.""" - return self.node.deviceName + """Return the name of the node.""" + return self.node.device_id @property def unique_id(self): """Return the ID of this Insteon node.""" - return 'insteon_local_{}_fan'.format(self.node.device_id) + return self.node.device_id @property def speed(self) -> str: @@ -174,7 +91,7 @@ def supported_features(self): """Flag supported features.""" return SUPPORT_INSTEON_LOCAL - def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn device on.""" if speed is None: if ATTR_SPEED in kwargs: diff --git a/homeassistant/components/fan/insteon_plm.py b/homeassistant/components/fan/insteon_plm.py new file mode 100644 index 0000000000000..0911295d090cf --- /dev/null +++ b/homeassistant/components/fan/insteon_plm.py @@ -0,0 +1,96 @@ +""" +Support for INSTEON fans via PowerLinc Modem. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/fan.insteon_plm/ +""" +import asyncio +import logging + +from homeassistant.components.fan import (SPEED_OFF, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_HIGH, + FanEntity, + SUPPORT_SET_SPEED) +from homeassistant.const import STATE_OFF +from homeassistant.components.insteon_plm import InsteonPLMEntity + +DEPENDENCIES = ['insteon_plm'] + +SPEED_TO_HEX = {SPEED_OFF: 0x00, + SPEED_LOW: 0x3f, + SPEED_MEDIUM: 0xbe, + SPEED_HIGH: 0xff} + +FAN_SPEEDS = [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + +_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'].get('plm') + + address = discovery_info['address'] + device = plm.devices[address] + state_key = discovery_info['state_key'] + + _LOGGER.debug('Adding device %s entity %s to Fan platform', + device.address.hex, device.states[state_key].name) + + new_entity = InsteonPLMFan(device, state_key) + + async_add_devices([new_entity]) + + +class InsteonPLMFan(InsteonPLMEntity, FanEntity): + """An INSTEON fan component.""" + + @property + def speed(self) -> str: + """Return the current speed.""" + return self._hex_to_speed(self._insteon_device_state.value) + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return FAN_SPEEDS + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED + + @asyncio.coroutine + def async_turn_on(self, speed: str = None, **kwargs) -> None: + """Turn on the entity.""" + if speed is None: + speed = SPEED_MEDIUM + yield from self.async_set_speed(speed) + + @asyncio.coroutine + def async_turn_off(self, **kwargs) -> None: + """Turn off the entity.""" + yield from self.async_set_speed(SPEED_OFF) + + @asyncio.coroutine + def async_set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + fan_speed = SPEED_TO_HEX[speed] + if fan_speed == 0x00: + self._insteon_device_state.off() + else: + self._insteon_device_state.set_level(fan_speed) + + @staticmethod + def _hex_to_speed(speed: int): + hex_speed = SPEED_OFF + if speed > 0xfe: + hex_speed = SPEED_HIGH + elif speed > 0x7f: + hex_speed = SPEED_MEDIUM + elif speed > 0: + hex_speed = SPEED_LOW + return hex_speed diff --git a/homeassistant/components/fan/isy994.py b/homeassistant/components/fan/isy994.py index 90cd161fa2000..847ca3b325b2b 100644 --- a/homeassistant/components/fan/isy994.py +++ b/homeassistant/components/fan/isy994.py @@ -9,18 +9,13 @@ from homeassistant.components.fan import (FanEntity, DOMAIN, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, - SPEED_HIGH) -import homeassistant.components.isy994 as isy -from homeassistant.const import STATE_UNKNOWN, STATE_ON, STATE_OFF + SPEED_HIGH, SUPPORT_SET_SPEED) +from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, + ISYDevice) from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) -# Define term used for medium speed. This must be set as the fan component uses -# 'medium' which the ISY does not understand -ISY_SPEED_MEDIUM = 'med' - - VALUE_TO_STATE = { 0: SPEED_OFF, 63: SPEED_LOW, @@ -34,101 +29,78 @@ for key in VALUE_TO_STATE: STATE_TO_VALUE[VALUE_TO_STATE[key]] = key -STATES = [SPEED_OFF, SPEED_LOW, ISY_SPEED_MEDIUM, SPEED_HIGH] - # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 fan platform.""" - if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error("A connection has not been made to the ISY controller") - return False - devices = [] - for node in isy.filter_nodes(isy.NODES, states=STATES): + for node in hass.data[ISY994_NODES][DOMAIN]: devices.append(ISYFanDevice(node)) - for program in isy.PROGRAMS.get(DOMAIN, []): - try: - status = program[isy.KEY_STATUS] - actions = program[isy.KEY_ACTIONS] - assert actions.dtype == 'program', 'Not a program' - except (KeyError, AssertionError): - pass - else: - devices.append(ISYFanProgram(program.name, status, actions)) + for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]: + devices.append(ISYFanProgram(name, status, actions)) add_devices(devices) -class ISYFanDevice(isy.ISYDevice, FanEntity): +class ISYFanDevice(ISYDevice, FanEntity): """Representation of an ISY994 fan device.""" - def __init__(self, node) -> None: - """Initialize the ISY994 fan device.""" - isy.ISYDevice.__init__(self, node) - @property def speed(self) -> str: """Return the current speed.""" - return self.state + return VALUE_TO_STATE.get(self.value) @property - def state(self) -> str: - """Get the state of the ISY994 fan device.""" - return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) + def is_on(self) -> bool: + """Get if the fan is on.""" + return self.value != 0 def set_speed(self, speed: str) -> None: """Send the set speed command to the ISY994 fan device.""" - if not self._node.on(val=STATE_TO_VALUE.get(speed, 0)): - _LOGGER.debug("Unable to set fan speed") - else: - self.speed = self.state + self._node.on(val=STATE_TO_VALUE.get(speed, 255)) - def turn_on(self, speed: str=None, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Send the turn on command to the ISY994 fan device.""" self.set_speed(speed) def turn_off(self, **kwargs) -> None: """Send the turn off command to the ISY994 fan device.""" - if not self._node.off(): - _LOGGER.debug("Unable to set fan speed") - else: - self.speed = self.state + self._node.off() @property def speed_list(self) -> list: """Get the list of available speeds.""" return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED + class ISYFanProgram(ISYFanDevice): """Representation of an ISY994 fan program.""" def __init__(self, name: str, node, actions) -> None: """Initialize the ISY994 fan program.""" - ISYFanDevice.__init__(self, node) + super().__init__(node) self._name = name self._actions = actions - self.speed = STATE_ON if self.is_on else STATE_OFF - - @property - def state(self) -> str: - """Get the state of the ISY994 fan program.""" - return STATE_ON if bool(self.value) else STATE_OFF def turn_off(self, **kwargs) -> None: """Send the turn on command to ISY994 fan program.""" if not self._actions.runThen(): _LOGGER.error("Unable to turn off the fan") - else: - self.speed = STATE_ON if self.is_on else STATE_OFF - def turn_on(self, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Send the turn off command to ISY994 fan program.""" if not self._actions.runElse(): _LOGGER.error("Unable to turn on the fan") - else: - self.speed = STATE_ON if self.is_on else STATE_OFF + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return 0 diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index bc732aa0aff83..6fa506edec66f 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -4,7 +4,6 @@ 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 @@ -15,8 +14,11 @@ CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, STATE_ON, STATE_OFF, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) + 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.components.fan import (SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, FanEntity, SUPPORT_SET_SPEED, SUPPORT_OSCILLATE, @@ -72,12 +74,15 @@ default=[SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]): cv.ensure_list, 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: 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) + async_add_devices([MqttFan( config.get(CONF_NAME), { @@ -108,15 +113,21 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): }, config.get(CONF_SPEED_LIST), config.get(CONF_OPTIMISTIC), + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), )]) -class MqttFan(FanEntity): +class MqttFan(MqttAvailability, FanEntity): """A MQTT fan component.""" def __init__(self, name, topic, templates, qos, retain, payload, - speed_list, optimistic): + speed_list, optimistic, availability_topic, payload_available, + payload_not_available): """Initialize the MQTT fan.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._name = name self._topic = topic self._qos = qos @@ -138,12 +149,10 @@ def __init__(self, name, topic, templates, qos, retain, payload, self._supported_features |= (topic[CONF_SPEED_STATE_TOPIC] is not None and SUPPORT_SET_SPEED) - @asyncio.coroutine - def async_added_to_hass(self): - """Subscribe to MQTT events. + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await super().async_added_to_hass() - This method is a coroutine. - """ templates = {} for key, tpl in list(self._templates.items()): if tpl is None: @@ -160,10 +169,10 @@ def state_received(topic, payload, qos): self._state = True elif payload == self._payload[STATE_OFF]: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + 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) @@ -177,13 +186,13 @@ def speed_received(topic, payload, qos): self._speed = SPEED_MEDIUM elif payload == self._payload[SPEED_HIGH]: self._speed = SPEED_HIGH - self.hass.async_add_job(self.async_update_ha_state()) + 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 + self._speed = SPEED_OFF @callback def oscillation_received(topic, payload, qos): @@ -193,13 +202,13 @@ def oscillation_received(topic, payload, qos): self._oscillation = True elif payload == self._payload[OSCILLATE_OFF_PAYLOAD]: self._oscillation = False - self.hass.async_add_job(self.async_update_ha_state()) + 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 + self._oscillation = False @property def should_poll(self): @@ -241,8 +250,7 @@ def oscillating(self): """Return the oscillation state.""" return self._oscillation - @asyncio.coroutine - def async_turn_on(self, speed: str=None) -> None: + async def async_turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the entity. This method is a coroutine. @@ -251,10 +259,9 @@ def async_turn_on(self, speed: str=None) -> None: 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) -> None: + async def async_turn_off(self, **kwargs) -> None: """Turn off the entity. This method is a coroutine. @@ -263,8 +270,7 @@ def async_turn_off(self) -> None: 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. @@ -287,10 +293,9 @@ def async_set_speed(self, speed: str) -> None: if self._optimistic_speed: self._speed = speed - self.hass.async_add_job(self.async_update_ha_state()) + 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. @@ -309,4 +314,4 @@ def async_oscillate(self, oscillating: bool) -> None: if self._optimistic_oscillation: self._oscillation = oscillating - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index 4a91f49e3829b..039cc33f748ff 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -1,71 +1,61 @@ # Describes the format for available fan services set_speed: - description: Sets fan speed - + description: Sets fan speed. fields: entity_id: description: Name(s) of the entities to set example: 'fan.living_room' - speed: description: Speed setting example: 'low' turn_on: - description: Turns fan on - + description: Turns fan on. fields: entity_id: description: Names(s) of the entities to turn on example: 'fan.living_room' - speed: description: Speed setting example: 'high' turn_off: - description: Turns fan off - + description: Turns fan off. fields: entity_id: description: Names(s) of the entities to turn off example: 'fan.living_room' oscillate: - description: Oscillates the fan - + description: Oscillates the fan. fields: entity_id: description: Name(s) of the entities to oscillate example: 'fan.desk_fan' - oscillating: description: Flag to turn on/off oscillation example: True toggle: - description: Toggle the fan on/off - + description: Toggle the fan on/off. fields: entity_id: description: Name(s) of the entities to toggle exampl: 'fan.living_room' set_direction: - description: Set the fan rotation direction - + description: Set the fan rotation. fields: entity_id: description: Name(s) of the entities to toggle 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 - + description: Set the fan in night mode. fields: entity_id: description: Name(s) of the entities to enable/disable night mode @@ -73,3 +63,144 @@ dyson_set_night_mode: night_mode: description: Night mode status example: true + +xiaomi_miio_set_buzzer_on: + description: Turn the buzzer on. + fields: + entity_id: + 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 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 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 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 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 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 xiaomi miio entity. + example: 'fan.xiaomi_miio_device' + level: + description: Level, between 0 and 16. + example: 1 + +xiaomi_miio_set_led_brightness: + description: Set the led brightness. + fields: + entity_id: + description: Name of the 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 0000000000000..a40437e719b96 --- /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/velbus.py b/homeassistant/components/fan/velbus.py index c0d125aa5ab6b..e8208d1c9907a 100644 --- a/homeassistant/components/fan/velbus.py +++ b/homeassistant/components/fan/velbus.py @@ -128,13 +128,13 @@ def speed_list(self): """Get the list of available speeds.""" return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def turn_on(self, speed, **kwargs): + def turn_on(self, speed=None, **kwargs): """Turn on the entity.""" if speed is None: speed = SPEED_MEDIUM self.set_speed(speed) - def turn_off(self): + def turn_off(self, **kwargs): """Turn off the entity.""" self.set_speed(STATE_OFF) diff --git a/homeassistant/components/fan/wink.py b/homeassistant/components/fan/wink.py index 3920e606d906c..0cebd9cb9f831 100644 --- a/homeassistant/components/fan/wink.py +++ b/homeassistant/components/fan/wink.py @@ -7,20 +7,18 @@ import asyncio import logging -from homeassistant.components.fan import (FanEntity, SPEED_HIGH, - SPEED_LOW, SPEED_MEDIUM, - STATE_UNKNOWN, SUPPORT_SET_SPEED, - SUPPORT_DIRECTION) +from homeassistant.components.fan import ( + SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, STATE_UNKNOWN, SUPPORT_DIRECTION, + SUPPORT_SET_SPEED, FanEntity) +from homeassistant.components.wink import DOMAIN, WinkDevice from homeassistant.helpers.entity import ToggleEntity -from homeassistant.components.wink import WinkDevice, DOMAIN - -DEPENDENCIES = ['wink'] _LOGGER = logging.getLogger(__name__) -SPEED_LOWEST = 'lowest' -SPEED_AUTO = 'auto' +DEPENDENCIES = ['wink'] +SPEED_AUTO = 'auto' +SPEED_LOWEST = 'lowest' SUPPORTED_FEATURES = SUPPORT_DIRECTION + SUPPORT_SET_SPEED @@ -38,7 +36,7 @@ class WinkFanDevice(WinkDevice, FanEntity): @asyncio.coroutine def async_added_to_hass(self): - """Callback when entity is added to hass.""" + """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['fan'].append(self) def set_direction(self: ToggleEntity, direction: str) -> None: @@ -49,7 +47,7 @@ def set_speed(self: ToggleEntity, speed: str) -> None: """Set the speed of the fan.""" self.wink.set_state(True, speed) - def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn on the fan.""" self.wink.set_state(True, speed) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py new file mode 100644 index 0000000000000..2acc3895f3e5a --- /dev/null +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -0,0 +1,815 @@ +""" +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.components.fan import (FanEntity, PLATFORM_SCHEMA, + SUPPORT_SET_SPEED, DOMAIN, ) +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, + ATTR_ENTITY_ID, ) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Xiaomi 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.9', 'construct==2.9.41'] + +ATTR_MODEL = 'model' + +# Air Purifier +ATTR_TEMPERATURE = 'temperature' +ATTR_HUMIDITY = 'humidity' +ATTR_AIR_QUALITY_INDEX = 'aqi' +ATTR_MODE = 'mode' +ATTR_FILTER_HOURS_USED = 'filter_hours_used' +ATTR_FILTER_LIFE = 'filter_life_remaining' +ATTR_FAVORITE_LEVEL = 'favorite_level' +ATTR_BUZZER = 'buzzer' +ATTR_CHILD_LOCK = 'child_lock' +ATTR_LED = 'led' +ATTR_LED_BRIGHTNESS = 'led_brightness' +ATTR_MOTOR_SPEED = 'motor_speed' +ATTR_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_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, +}) + +SERVICE_SCHEMA_LED_BRIGHTNESS = AIRPURIFIER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_BRIGHTNESS): + vol.All(vol.Coerce(int), vol.Clamp(min=0, max=2)) +}) + +SERVICE_SCHEMA_FAVORITE_LEVEL = AIRPURIFIER_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_LEVEL): + vol.All(vol.Coerce(int), vol.Clamp(min=0, max=16)) +}) + +SERVICE_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'}, + SERVICE_SET_LED_ON: {'method': 'async_set_led_on'}, + 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_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 +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 + + 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 + + hass.data[DATA_KEY][host] = device + async_add_devices([device], update_before_add=True) + + 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[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 air_purifier_service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[air_purifier_service].get( + 'schema', AIRPURIFIER_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, air_purifier_service, async_service_handler, schema=schema) + + +class XiaomiGenericDevice(FanEntity): + """Representation of a generic Xiaomi device.""" + + 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._available = False + self._state = None + self._state_attrs = { + ATTR_MODEL: self._model, + } + self._device_features = FEATURE_FLAGS_GENERIC + self._skip_update = False + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_SET_SPEED + + @property + def should_poll(self): + """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.""" + return self._name + + @property + def available(self): + """Return true when state is known.""" + return self._available + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state_attrs + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @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 = await self.hass.async_add_job( + partial(func, *args, **kwargs)) + + _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 + + 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 = await self.async_set_speed(speed) + else: + result = await self._try_command( + "Turning the miio device on failed.", self._device.on) + + if result: + self._state = True + self._skip_update = True + + 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 + + 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 + + # 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.airpurifier 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.airpurifier 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_on(self): + """Turn the led on.""" + if self._device_features & FEATURE_SET_LED == 0: + return + + 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.""" + if self._device_features & FEATURE_SET_LED == 0: + return + + await self._try_command( + "Turning the led of the miio device off failed.", + self._device.set_led, False) + + 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 + + await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, LedBrightness(brightness)) + + async def async_set_favorite_level(self, level: int = 1): + """Set the favorite 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 0000000000000..01b1d0a92cf87 --- /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 3d73901b4d8a4..61fbe9f3171e5 100644 --- a/homeassistant/components/feedreader.py +++ b/homeassistant/components/feedreader.py @@ -55,16 +55,28 @@ def __init__(self, url, hass, storage): 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_utc_time_change( + hass, lambda now: self._update(), minute=0, second=0) + + @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 +88,39 @@ def _update(self): 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) > 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] + 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 +134,12 @@ def _update_and_fire_entry(self, entry): _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: @@ -153,27 +178,25 @@ def _fetch_data(self): with self._lock, open(self._data_file, 'rb') as myfile: self._data = pickle.load(myfile) or {} self._cache_outdated = False - # pylint: disable=bare-except - except: + except: # noqa: E722 # pylint: disable=bare-except _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) - # pylint: disable=bare-except - except: + except: # noqa: E722 # pylint: disable=bare-except _LOGGER.error( "Error saving pickled data to %s", self._data_file) self._cache_outdated = True diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index 887d07e5855a1..e083affe92bcc 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -6,20 +6,18 @@ """ import asyncio import logging -import os import voluptuous as vol from homeassistant.core import callback from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) -from homeassistant.config import load_yaml_config_file from homeassistant.helpers.dispatcher import ( async_dispatcher_send, async_dispatcher_connect) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['ha-ffmpeg==1.7'] +REQUIREMENTS = ['ha-ffmpeg==1.9'] DOMAIN = 'ffmpeg' @@ -89,10 +87,6 @@ def async_setup(hass, config): conf.get(CONF_RUN_TEST, DEFAULT_RUN_TEST) ) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - # Register service @asyncio.coroutine def async_service_handle(service): @@ -108,15 +102,14 @@ def async_service_handle(service): hass.services.async_register( DOMAIN, SERVICE_START, async_service_handle, - descriptions[DOMAIN].get(SERVICE_START), schema=SERVICE_FFMPEG_SCHEMA) + schema=SERVICE_FFMPEG_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_STOP, async_service_handle, - descriptions[DOMAIN].get(SERVICE_STOP), schema=SERVICE_FFMPEG_SCHEMA) + schema=SERVICE_FFMPEG_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_RESTART, async_service_handle, - descriptions[DOMAIN].get(SERVICE_RESTART), schema=SERVICE_FFMPEG_SCHEMA) hass.data[DATA_FFMPEG] = manager @@ -242,7 +235,7 @@ def async_shutdown_handle(event): def async_start_handle(event): """Start FFmpeg process.""" yield from self._async_start_ffmpeg(None) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, async_start_handle) diff --git a/homeassistant/components/folder_watcher.py b/homeassistant/components/folder_watcher.py new file mode 100644 index 0000000000000..098b34ac948cf --- /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/foursquare.py b/homeassistant/components/foursquare.py index 61c5e9b1da645..2c10df327f438 100644 --- a/homeassistant/components/foursquare.py +++ b/homeassistant/components/foursquare.py @@ -6,14 +6,12 @@ """ import asyncio import logging -import os import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_BAD_REQUEST -from homeassistant.config import load_yaml_config_file from homeassistant.components.http import HomeAssistantView _LOGGER = logging.getLogger(__name__) @@ -50,9 +48,6 @@ def setup(hass, config): """Set up the Foursquare component.""" - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - config = config[DOMAIN] def checkin_user(call): @@ -72,7 +67,6 @@ def checkin_user(call): # Register our service with Home Assistant. hass.services.register(DOMAIN, 'checkin', checkin_user, - descriptions[DOMAIN][SERVICE_CHECKIN], schema=CHECKIN_SERVICE_SCHEMA) hass.http.register_view(FoursquarePushReceiver(config[CONF_PUSH_SECRET])) diff --git a/homeassistant/components/freedns.py b/homeassistant/components/freedns.py new file mode 100644 index 0000000000000..0512030bdcb2e --- /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 0000000000000..a3c35aaa59719 --- /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 112c93403b007..3ea8594b2a0a5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,35 +1,47 @@ -"""Handle the frontend for Home Assistant.""" +""" +Handle the frontend for Home Assistant. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/frontend/ +""" import asyncio import hashlib import json import logging import os +from urllib.parse import urlparse from aiohttp import web import voluptuous as vol -import homeassistant.helpers.config_validation as cv +import jinja2 +import homeassistant.helpers.config_validation as cv +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 -from homeassistant.components import api -from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.auth import is_trusted_ip -from homeassistant.components.http.const import KEY_DEVELOPMENT -from .version import FINGERPRINTS + +REQUIREMENTS = ['home-assistant-frontend==20180516.1'] DOMAIN = 'frontend' -DEPENDENCIES = ['api', 'websocket_api'] +DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] -URL_PANEL_COMPONENT = '/frontend/panels/{}.html' URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html' -STATIC_PATH = os.path.join(os.path.dirname(__file__), 'www_static/') +CONF_THEMES = 'themes' +CONF_EXTRA_HTML_URL = 'extra_html_url' +CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5' +CONF_FRONTEND_REPO = 'development_repo' +CONF_JS_VERSION = 'javascript_version' +JS_DEFAULT_OPTION = 'auto' +JS_OPTIONS = ['es5', 'latest', 'auto'] -ATTR_THEMES = 'themes' -ATTR_EXTRA_HTML_URL = 'extra_html_url' DEFAULT_THEME_COLOR = '#03A9F4' + MANIFEST_JSON = { 'background_color': '#FFFFFF', 'description': 'Open-source home automation platform running on Python 3.', @@ -39,7 +51,7 @@ 'lang': 'en-US', 'name': 'Home Assistant', 'short_name': 'Assistant', - 'start_url': '/', + 'start_url': '/states', 'theme_color': DEFAULT_THEME_COLOR } @@ -50,26 +62,31 @@ 'type': 'image/png' }) +DATA_FINALIZE_PANEL = 'frontend_finalize_panel' DATA_PANELS = 'frontend_panels' +DATA_JS_VERSION = 'frontend_js_version' DATA_EXTRA_HTML_URL = 'frontend_extra_html_url' -DATA_INDEX_VIEW = 'frontend_index_view' +DATA_EXTRA_HTML_URL_ES5 = 'frontend_extra_html_url_es5' DATA_THEMES = 'frontend_themes' DATA_DEFAULT_THEME = 'frontend_default_theme' DEFAULT_THEME = 'default' PRIMARY_COLOR = 'primary-color' -# To keep track we don't register a component twice (gives a warning) -_REGISTERED_COMPONENTS = set() _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(ATTR_THEMES): vol.Schema({ + vol.Optional(CONF_FRONTEND_REPO): cv.isdir, + vol.Optional(CONF_THEMES): vol.Schema({ cv.string: {cv.string: cv.string} }), - vol.Optional(ATTR_EXTRA_HTML_URL): + vol.Optional(CONF_EXTRA_HTML_URL): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXTRA_HTML_URL_ES5): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_JS_VERSION, default=JS_DEFAULT_OPTION): + vol.In(JS_OPTIONS) }), }, extra=vol.ALLOW_EXTRA) @@ -78,108 +95,181 @@ 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, +}) -@bind_hass -def register_built_in_panel(hass, component_name, sidebar_title=None, - sidebar_icon=None, url_path=None, config=None): - """Register a built-in panel.""" - nondev_path = 'panels/ha-panel-{}.html'.format(component_name) - - if hass.http.development: - url = ('/static/home-assistant-polymer/panels/' - '{0}/ha-panel-{0}.html'.format(component_name)) - path = os.path.join( - STATIC_PATH, 'home-assistant-polymer/panels/', - '{0}/ha-panel-{0}.html'.format(component_name)) - else: - url = None # use default url generate mechanism - path = os.path.join(STATIC_PATH, nondev_path) +class AbstractPanel: + """Abstract class for panels.""" - # Fingerprint doesn't exist when adding new built-in panel - register_panel(hass, component_name, path, - FINGERPRINTS.get(nondev_path, 'dev'), sidebar_title, - sidebar_icon, url_path, url, config) + # Name of the webcomponent + component_name = None + # Icon to show in the sidebar (optional) + sidebar_icon = None -@bind_hass -def register_panel(hass, component_name, path, md5=None, sidebar_title=None, - sidebar_icon=None, url_path=None, url=None, config=None): - """Register a panel for the frontend. + # Title to show in the sidebar (optional) + sidebar_title = None - component_name: name of the web component - path: path to the HTML of the web component - (required unless url is provided) - md5: the md5 hash of the web component (for versioning, optional) - sidebar_title: title to show in the sidebar (optional) - sidebar_icon: icon to show next to title in sidebar (optional) - url_path: name to use in the url (defaults to component_name) - url: for the web component (optional) - config: config to be passed into the web component - """ - panels = hass.data.get(DATA_PANELS) - if panels is None: - panels = hass.data[DATA_PANELS] = {} + # Url to the webcomponent (depending on JS version) + webcomponent_url_es5 = None + webcomponent_url_latest = None - if url_path is None: - url_path = component_name + # Url to show the panel in the frontend + frontend_url_path = None - if url_path in panels: - _LOGGER.warning("Overwriting component %s", url_path) + # Config to pass to the webcomponent + config = None - if url is None: - if not os.path.isfile(path): - _LOGGER.error( - "Panel %s component does not exist: %s", component_name, path) - return + @asyncio.coroutine + def async_register(self, hass): + """Register panel with HASS.""" + panels = hass.data.get(DATA_PANELS) + if panels is None: + panels = hass.data[DATA_PANELS] = {} - if md5 is None: - with open(path) as fil: - md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() + if self.frontend_url_path in panels: + _LOGGER.warning("Overwriting component %s", self.frontend_url_path) - data = { - 'url_path': url_path, - 'component_name': component_name, - } + if DATA_FINALIZE_PANEL in hass.data: + yield from hass.data[DATA_FINALIZE_PANEL](self) - if sidebar_title: - data['title'] = sidebar_title - if sidebar_icon: - data['icon'] = sidebar_icon - if config is not None: - data['config'] = config + panels[self.frontend_url_path] = self - if url is not None: - data['url'] = url - else: - url = URL_PANEL_COMPONENT.format(component_name) + @callback + def async_register_index_routes(self, router, index_view): + """Register routes for panel to be served by index view.""" + router.add_route( + 'get', '/{}'.format(self.frontend_url_path), index_view.get) + router.add_route( + 'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path), + index_view.get) + + +class BuiltInPanel(AbstractPanel): + """Panel that is part of hass_frontend.""" + + def __init__(self, component_name, sidebar_title, sidebar_icon, + frontend_url_path, config): + """Initialize a built-in panel.""" + self.component_name = component_name + self.sidebar_title = sidebar_title + self.sidebar_icon = sidebar_icon + self.frontend_url_path = frontend_url_path or component_name + self.config = config + + 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): + """Panel that is added by a custom component.""" + + REGISTERED_COMPONENTS = set() + + def __init__(self, component_name, path, md5, sidebar_title, sidebar_icon, + frontend_url_path, config): + """Initialize an external panel.""" + self.component_name = component_name + self.path = path + self.md5 = md5 + self.sidebar_title = sidebar_title + self.sidebar_icon = sidebar_icon + self.frontend_url_path = frontend_url_path or component_name + self.config = config - if url not in _REGISTERED_COMPONENTS: - hass.http.register_static_path(url, path) - _REGISTERED_COMPONENTS.add(url) + @asyncio.coroutine + def async_finalize(self, hass, frontend_repository_path): + """Finalize this panel for usage. + + frontend_repository_path is set, will be prepended to path of built-in + components. + """ + try: + if self.md5 is None: + self.md5 = yield from hass.async_add_job( + _fingerprint, self.path) + except OSError: + _LOGGER.error('Cannot find or access %s at %s', + self.component_name, self.path) + hass.data[DATA_PANELS].pop(self.frontend_url_path) + return - fprinted_url = URL_PANEL_COMPONENT_FP.format(component_name, md5) - data['url'] = fprinted_url + self.webcomponent_url_es5 = self.webcomponent_url_latest = \ + URL_PANEL_COMPONENT_FP.format(self.component_name, self.md5) + + if self.component_name not in self.REGISTERED_COMPONENTS: + hass.http.register_static_path( + self.webcomponent_url_latest, self.path, + # if path is None, we're in prod mode, so cache static assets + frontend_repository_path is None) + self.REGISTERED_COMPONENTS.add(self.component_name) + + 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 +def async_register_built_in_panel(hass, component_name, sidebar_title=None, + sidebar_icon=None, frontend_url_path=None, + config=None): + """Register a built-in panel.""" + panel = BuiltInPanel(component_name, sidebar_title, sidebar_icon, + frontend_url_path, config) + yield from panel.async_register(hass) - panels[url_path] = data - # Register index view for this route if IndexView already loaded - # Otherwise it will be done during setup. - index_view = hass.data.get(DATA_INDEX_VIEW) +@bind_hass +@asyncio.coroutine +def async_register_panel(hass, component_name, path, md5=None, + sidebar_title=None, sidebar_icon=None, + frontend_url_path=None, config=None): + """Register a panel for the frontend. - if index_view: - hass.http.app.router.add_route( - 'get', '/{}'.format(url_path), index_view.get) - hass.http.app.router.add_route( - 'get', '/{}/{{extra:.+}}'.format(url_path), index_view.get) + component_name: name of the web component + path: path to the HTML of the web component + (required unless url is provided) + md5: the md5 hash of the web component (for versioning in URL, optional) + sidebar_title: title to show in the sidebar (optional) + sidebar_icon: icon to show next to title in sidebar (optional) + url_path: name to use in the URL (defaults to component_name) + config: config to be passed into the web component + """ + panel = ExternalPanel(component_name, path, md5, sidebar_title, + sidebar_icon, frontend_url_path, config) + yield from panel.async_register(hass) @bind_hass -def add_extra_html_url(hass, url): +@callback +def add_extra_html_url(hass, url, es5=False): """Register extra html url to load.""" - url_set = hass.data.get(DATA_EXTRA_HTML_URL) + key = DATA_EXTRA_HTML_URL_ES5 if es5 else DATA_EXTRA_HTML_URL + url_set = hass.data.get(key) if url_set is None: - url_set = hass.data[DATA_EXTRA_HTML_URL] = set() + url_set = hass.data[key] = set() url_set.add(url) @@ -188,59 +278,115 @@ def add_manifest_json_key(key, val): MANIFEST_JSON[key] = val -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Set up the serving of the frontend.""" - hass.http.register_view(BootstrapView) + 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) - if hass.http.development: - sw_path = "home-assistant-polymer/build/service_worker.js" + conf = config.get(DOMAIN, {}) + + repo_path = conf.get(CONF_FRONTEND_REPO) + is_dev = repo_path is not None + 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') else: - sw_path = "service_worker.js" - - hass.http.register_static_path("/service_worker.js", - os.path.join(STATIC_PATH, sw_path), False) - hass.http.register_static_path("/robots.txt", - os.path.join(STATIC_PATH, "robots.txt")) - hass.http.register_static_path("/static", STATIC_PATH) + 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.http.register_static_path( + "/service_worker_es5.js", sw_path_es5, False) + hass.http.register_static_path( + "/service_worker.js", sw_path_latest, 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) + hass.http.register_static_path( + "/frontend_latest", frontend_latest_path, not is_dev) + hass.http.register_static_path( + "/frontend_es5", frontend_es5_path, not is_dev) local = hass.config.path('www') if os.path.isdir(local): - hass.http.register_static_path("/local", local) + hass.http.register_static_path("/local", local, not is_dev) - index_view = hass.data[DATA_INDEX_VIEW] = IndexView() + index_view = IndexView(repo_path, js_version, client) hass.http.register_view(index_view) - # Components have registered panels before frontend got setup. - # Now register their urls. - if DATA_PANELS in hass.data: - for url_path in hass.data[DATA_PANELS]: - hass.http.app.router.add_route( - 'get', '/{}'.format(url_path), index_view.get) - hass.http.app.router.add_route( - 'get', '/{}/{{extra:.+}}'.format(url_path), index_view.get) - else: - hass.data[DATA_PANELS] = {} + async def finalize_panel(panel): + """Finalize setup of a panel.""" + 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([ + async_register_built_in_panel(hass, panel) + for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', + 'dev-template', 'dev-mqtt', 'kiosk')], loop=hass.loop) + + hass.data[DATA_FINALIZE_PANEL] = finalize_panel + + # Finalize registration of panels that registered before frontend was setup + # This includes the built-in panels from line above. + yield from asyncio.wait( + [finalize_panel(panel) for panel in hass.data[DATA_PANELS].values()], + loop=hass.loop) if DATA_EXTRA_HTML_URL not in hass.data: hass.data[DATA_EXTRA_HTML_URL] = set() + if DATA_EXTRA_HTML_URL_ES5 not in hass.data: + hass.data[DATA_EXTRA_HTML_URL_ES5] = set() - register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location') - - for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt', 'kiosk'): - register_built_in_panel(hass, panel) + for url in conf.get(CONF_EXTRA_HTML_URL, []): + add_extra_html_url(hass, url, False) + for url in conf.get(CONF_EXTRA_HTML_URL_ES5, []): + add_extra_html_url(hass, url, True) - themes = config.get(DOMAIN, {}).get(ATTR_THEMES) - setup_themes(hass, themes) + async_setup_themes(hass, conf.get(CONF_THEMES)) - for url in config.get(DOMAIN, {}).get(ATTR_EXTRA_HTML_URL, []): - add_extra_html_url(hass, url) + hass.http.register_view(TranslationsView) return True -def setup_themes(hass, themes): +def async_setup_themes(hass, themes): """Set up themes data and services.""" hass.http.register_view(ThemesView) hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME @@ -266,7 +412,7 @@ def update_theme_and_fire_event(): @callback def set_theme(call): - """Set backend-prefered theme.""" + """Set backend-preferred theme.""" data = call.data name = data[CONF_NAME] if name == DEFAULT_THEME or name in hass.data[DATA_THEMES]: @@ -280,40 +426,15 @@ def set_theme(call): def reload_themes(_): """Reload themes.""" path = find_config_file(hass.config.config_dir) - new_themes = load_yaml_config_file(path)[DOMAIN].get(ATTR_THEMES, {}) + new_themes = load_yaml_config_file(path)[DOMAIN].get(CONF_THEMES, {}) hass.data[DATA_THEMES] = new_themes if hass.data[DATA_DEFAULT_THEME] not in new_themes: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME update_theme_and_fire_event() - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - hass.services.register(DOMAIN, SERVICE_SET_THEME, - set_theme, - descriptions[SERVICE_SET_THEME], - SERVICE_SET_THEME_SCHEMA) - hass.services.register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes, - descriptions[SERVICE_RELOAD_THEMES]) - - -class BootstrapView(HomeAssistantView): - """View to bootstrap frontend with all needed data.""" - - url = '/api/bootstrap' - name = 'api:bootstrap' - - @callback - def get(self, request): - """Return all data needed to bootstrap Home Assistant.""" - hass = request.app['hass'] - - return self.json({ - 'config': hass.config.as_dict(), - 'states': hass.states.async_all(), - 'events': api.async_events_json(hass), - 'services': api.async_services_json(hass), - 'panels': hass.data[DATA_PANELS], - }) + hass.services.async_register( + DOMAIN, SERVICE_SET_THEME, set_theme, schema=SERVICE_SET_THEME_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes) class IndexView(HomeAssistantView): @@ -324,33 +445,42 @@ class IndexView(HomeAssistantView): requires_auth = False extra_urls = ['/states', '/states/{extra}'] - def __init__(self): + def __init__(self, repo_path, js_option, client): """Initialize the frontend view.""" - from jinja2 import FileSystemLoader, Environment + self.repo_path = repo_path + self.js_option = js_option + self.client = client + self._template_cache = {} + + def get_template(self, latest): + """Get template.""" + if self.repo_path is not None: + root = self.repo_path + elif latest: + import hass_frontend + root = hass_frontend.where() + else: + import hass_frontend_es5 + root = hass_frontend_es5.where() - self.templates = Environment( - loader=FileSystemLoader( - os.path.join(os.path.dirname(__file__), 'templates/') - ) - ) + tpl = self._template_cache.get(root) + + if tpl is None: + with open(os.path.join(root, 'index.html')) as file: + tpl = jinja2.Template(file.read()) + + # Cache template if not running from repository + if self.repo_path is None: + self._template_cache[root] = tpl + + return tpl @asyncio.coroutine def get(self, request, extra=None): """Serve the index view.""" hass = request.app['hass'] - - if request.app[KEY_DEVELOPMENT]: - core_url = '/static/home-assistant-polymer/build/core.js' - compatibility_url = \ - '/static/home-assistant-polymer/build/compatibility.js' - ui_url = '/static/home-assistant-polymer/src/home-assistant.html' - else: - core_url = '/static/core-{}.js'.format( - FINGERPRINTS['core.js']) - compatibility_url = '/static/compatibility-{}.js'.format( - FINGERPRINTS['compatibility.js']) - ui_url = '/static/frontend-{}.html'.format( - FINGERPRINTS['frontend.html']) + latest = self.repo_path is not None or \ + _is_latest(self.js_option, request) if request.path == '/': panel = 'states' @@ -359,34 +489,33 @@ def get(self, request, extra=None): if panel == 'states': panel_url = '' + elif latest: + panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_latest else: - panel_url = hass.data[DATA_PANELS][panel]['url'] - - no_auth = 'true' - if hass.config.api.api_password: - # require password if set - no_auth = 'false' - if is_trusted_ip(request): - # bypass for trusted networks - no_auth = 'true' - - icons_url = '/static/mdi-{}.html'.format(FINGERPRINTS['mdi.html']) - template = yield from hass.async_add_job( - self.templates.get_template, 'index.html') - - # pylint is wrong - # pylint: disable=no-member - # This is a jinja2 template, not a HA template so we call 'render'. - resp = template.render( - core_url=core_url, ui_url=ui_url, - compatibility_url=compatibility_url, no_auth=no_auth, - icons_url=icons_url, icons=FINGERPRINTS['mdi.html'], - panel_url=panel_url, panels=hass.data[DATA_PANELS], - dev_mode=request.app[KEY_DEVELOPMENT], + panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_es5 + + no_auth = '1' + if hass.config.api.api_password and not request[KEY_AUTHENTICATED]: + # do not try to auto connect on load + no_auth = '0' + + template = yield from hass.async_add_job(self.get_template, latest) + + extra_key = DATA_EXTRA_HTML_URL if latest else DATA_EXTRA_HTML_URL_ES5 + + template_params = dict( + no_auth=no_auth, + panel_url=panel_url, + panels=hass.data[DATA_PANELS], theme_color=MANIFEST_JSON['theme_color'], - extra_urls=hass.data[DATA_EXTRA_HTML_URL]) + 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): @@ -399,8 +528,8 @@ class ManifestJSONView(HomeAssistantView): @asyncio.coroutine def get(self, request): # pylint: disable=no-self-use """Return the manifest.json.""" - msg = json.dumps(MANIFEST_JSON, sort_keys=True).encode('UTF-8') - return web.Response(body=msg, content_type="application/manifest+json") + msg = json.dumps(MANIFEST_JSON, sort_keys=True) + return web.Response(text=msg, content_type="application/manifest+json") class ThemesView(HomeAssistantView): @@ -419,3 +548,74 @@ def get(self, request): 'themes': hass.data[DATA_THEMES], 'default_theme': hass.data[DATA_DEFAULT_THEME], }) + + +class TranslationsView(HomeAssistantView): + """View to return backend defined translations.""" + + url = '/api/translations/{language}' + name = 'api:translations' + + @asyncio.coroutine + def get(self, request, language): + """Return translations.""" + hass = request.app['hass'] + + resources = yield from async_get_translations(hass, language) + return self.json({ + 'resources': resources, + }) + + +def _fingerprint(path): + """Fingerprint a file.""" + with open(path) as fil: + return hashlib.md5(fil.read().encode('utf-8')).hexdigest() + + +def _is_latest(js_option, request): + """ + Return whether we should serve latest untranspiled code. + + Set according to user's preference and URL override. + """ + import hass_frontend + + if request is None: + return js_option == 'latest' + + # latest in query + if 'latest' in request.query or ( + request.headers.get('Referer') and + 'latest' in urlparse(request.headers['Referer']).query): + return True + + # es5 in query + if 'es5' in request.query or ( + request.headers.get('Referer') and + 'es5' in urlparse(request.headers['Referer']).query): + return False + + # non-auto option in config + if js_option != 'auto': + return js_option == 'latest' + + 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/frontend/services.yaml b/homeassistant/components/frontend/services.yaml index 7d56cbb769354..dc1fb40be4841 100644 --- a/homeassistant/components/frontend/services.yaml +++ b/homeassistant/components/frontend/services.yaml @@ -8,4 +8,4 @@ set_theme: example: 'light' reload_themes: - description: Reload themes from yaml config. + description: Reload themes from yaml configuration. diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html deleted file mode 100644 index 6d199a86a507c..0000000000000 --- a/homeassistant/components/frontend/templates/index.html +++ /dev/null @@ -1,118 +0,0 @@ - - - - - Home Assistant - - - - - - - {% for panel in panels.values() -%} - - {% endfor -%} - - - - - - - - - - - - - -
    -
    - Home Assistant had trouble
    connecting to the server.

    - TRY AGAIN -
    -
    - - {# #} - - - {% if not dev_mode %} - - {% endif %} - - {% if panel_url -%} - - {% endif -%} - - {% for extra_url in extra_urls -%} - - {% endfor -%} - - - - diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py deleted file mode 100644 index 21215e14d2378..0000000000000 --- a/homeassistant/components/frontend/version.py +++ /dev/null @@ -1,24 +0,0 @@ -"""DO NOT MODIFY. Auto-generated by script/fingerprint_frontend.""" - -FINGERPRINTS = { - "compatibility.js": "1686167ff210e001f063f5c606b2e74b", - "core.js": "2a7d01e45187c7d4635da05065b5e54e", - "frontend.html": "c04709d3517dd3fd34b2f7d6bba6ec8e", - "mdi.html": "89074face5529f5fe6fbae49ecb3e88b", - "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-config.html": "0091008947ed61a6691c28093a6a6fcd", - "panels/ha-panel-dev-event.html": "d409e7ab537d9fe629126d122345279c", - "panels/ha-panel-dev-info.html": "b0e55eb657fd75f21aba2426ac0cedc0", - "panels/ha-panel-dev-mqtt.html": "94b222b013a98583842de3e72d5888c6", - "panels/ha-panel-dev-service.html": "422b2c181ee0713fa31d45a64e605baf", - "panels/ha-panel-dev-state.html": "7948d3dba058f31517d880df8ed0e857", - "panels/ha-panel-dev-template.html": "928e7b81b9c113b70edc9f4a1d051827", - "panels/ha-panel-hassio.html": "b46e7619f3c355f872d5370741d89f6a", - "panels/ha-panel-history.html": "fe2daac10a14f51fa3eb7d23978df1f7", - "panels/ha-panel-iframe.html": "56930204d6e067a3d600cf030f4b34c8", - "panels/ha-panel-kiosk.html": "b40aa5cb52dd7675bea744afcf9eebf8", - "panels/ha-panel-logbook.html": "771afdcf48dc7e308b0282417d2e02d8", - "panels/ha-panel-mailbox.html": "a8cca44ca36553e91565e3c894ea6323", - "panels/ha-panel-map.html": "565db019147162080c21af962afc097f", - "panels/ha-panel-shopping-list.html": "d8cfd0ecdb3aa6214c0f6908c34c7141" -} diff --git a/homeassistant/components/frontend/www_static/compatibility.js b/homeassistant/components/frontend/www_static/compatibility.js deleted file mode 100644 index 566f3310d9ae7..0000000000000 --- a/homeassistant/components/frontend/www_static/compatibility.js +++ /dev/null @@ -1 +0,0 @@ -!function(){"use strict";function e(e,t){if(void 0===e||null===e)throw new TypeError("Cannot convert first argument to object");for(var r=Object(e),n=1;n{'use strict';if(!window.customElements)return;const a=window.HTMLElement,b=window.customElements.define,c=window.customElements.get,d=new Map,e=new Map;let f=!1,g=!1;window.HTMLElement=function(){if(!f){const a=d.get(this.constructor),b=c.call(window.customElements,a);g=!0;const e=new b;return e}f=!1;},window.HTMLElement.prototype=a.prototype;Object.defineProperty(window,'customElements',{value:window.customElements,configurable:!0,writable:!0}),Object.defineProperty(window.customElements,'define',{value:(c,h)=>{const i=h.prototype,j=class extends a{constructor(){super(),Object.setPrototypeOf(this,i),g||(f=!0,h.call(this)),g=!1;}},k=j.prototype;j.observedAttributes=h.observedAttributes,k.connectedCallback=i.connectedCallback,k.disconnectedCallback=i.disconnectedCallback,k.attributeChangedCallback=i.attributeChangedCallback,k.adoptedCallback=i.adoptedCallback,d.set(h,c),e.set(c,h),b.call(window.customElements,c,j);},configurable:!0,writable:!0}),Object.defineProperty(window.customElements,'get',{value:(a)=>e.get(a),configurable:!0,writable:!0});})(); - -/** -@license -Copyright (c) 2017 The Polymer Project Authors. All rights reserved. -This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt -The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt -The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt -Code distributed by Google as part of the polymer project is also -subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt -*/ - -}()); diff --git a/homeassistant/components/frontend/www_static/custom-elements-es5-adapter.js.gz b/homeassistant/components/frontend/www_static/custom-elements-es5-adapter.js.gz deleted file mode 100644 index 42759b325adb4..0000000000000 Binary files a/homeassistant/components/frontend/www_static/custom-elements-es5-adapter.js.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/COPYRIGHT.txt b/homeassistant/components/frontend/www_static/fonts/roboto/COPYRIGHT.txt deleted file mode 100644 index a7ef69930cbf0..0000000000000 --- a/homeassistant/components/frontend/www_static/fonts/roboto/COPYRIGHT.txt +++ /dev/null @@ -1 +0,0 @@ -Copyright 2011 Google Inc. All Rights Reserved. \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/DESCRIPTION.en_us.html b/homeassistant/components/frontend/www_static/fonts/roboto/DESCRIPTION.en_us.html deleted file mode 100644 index 3a6834fd4c468..0000000000000 --- a/homeassistant/components/frontend/www_static/fonts/roboto/DESCRIPTION.en_us.html +++ /dev/null @@ -1,17 +0,0 @@ -

    Roboto has a dual nature. It has a mechanical skeleton and the forms are -largely geometric. At the same time, the font features friendly and open -curves. While some grotesks distort their letterforms to force a rigid rhythm, -Roboto doesn’t compromise, allowing letters to be settled into their natural -width. This makes for a more natural reading rhythm more commonly found in -humanist and serif types.

    - -

    This is the normal family, which can be used alongside the -Roboto Condensed family and the -Roboto Slab family.

    - -

    -Updated January 14 2015: -Christian Robertson and the Material Design team unveiled the latest version of Roboto at Google I/O last year, and it is now available from Google Fonts. -Existing websites using Roboto via Google Fonts will start using the latest version automatically. -If you have installed the fonts on your computer, please download them again and re-install. -

    \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/LICENSE.txt b/homeassistant/components/frontend/www_static/fonts/roboto/LICENSE.txt deleted file mode 100644 index d645695673349..0000000000000 --- a/homeassistant/components/frontend/www_static/fonts/roboto/LICENSE.txt +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/METADATA.json b/homeassistant/components/frontend/www_static/fonts/roboto/METADATA.json deleted file mode 100644 index 061bc67688bea..0000000000000 --- a/homeassistant/components/frontend/www_static/fonts/roboto/METADATA.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "name": "Roboto", - "designer": "Christian Robertson", - "license": "Apache2", - "visibility": "External", - "category": "Sans Serif", - "size": 86523, - "fonts": [ - { - "name": "Roboto", - "style": "normal", - "weight": 100, - "filename": "Roboto-Thin.ttf", - "postScriptName": "Roboto-Thin", - "fullName": "Roboto Thin", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "italic", - "weight": 100, - "filename": "Roboto-ThinItalic.ttf", - "postScriptName": "Roboto-ThinItalic", - "fullName": "Roboto Thin Italic", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "normal", - "weight": 300, - "filename": "Roboto-Light.ttf", - "postScriptName": "Roboto-Light", - "fullName": "Roboto Light", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "italic", - "weight": 300, - "filename": "Roboto-LightItalic.ttf", - "postScriptName": "Roboto-LightItalic", - "fullName": "Roboto Light Italic", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "normal", - "weight": 400, - "filename": "Roboto-Regular.ttf", - "postScriptName": "Roboto-Regular", - "fullName": "Roboto", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "italic", - "weight": 400, - "filename": "Roboto-Italic.ttf", - "postScriptName": "Roboto-Italic", - "fullName": "Roboto Italic", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "normal", - "weight": 500, - "filename": "Roboto-Medium.ttf", - "postScriptName": "Roboto-Medium", - "fullName": "Roboto Medium", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "italic", - "weight": 500, - "filename": "Roboto-MediumItalic.ttf", - "postScriptName": "Roboto-MediumItalic", - "fullName": "Roboto Medium Italic", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "normal", - "weight": 700, - "filename": "Roboto-Bold.ttf", - "postScriptName": "Roboto-Bold", - "fullName": "Roboto Bold", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "italic", - "weight": 700, - "filename": "Roboto-BoldItalic.ttf", - "postScriptName": "Roboto-BoldItalic", - "fullName": "Roboto Bold Italic", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "normal", - "weight": 900, - "filename": "Roboto-Black.ttf", - "postScriptName": "Roboto-Black", - "fullName": "Roboto Black", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto", - "style": "italic", - "weight": 900, - "filename": "Roboto-BlackItalic.ttf", - "postScriptName": "Roboto-BlackItalic", - "fullName": "Roboto Black Italic", - "copyright": "Copyright 2011 Google Inc. All Rights Reserved." - } - ], - "subsets": [ - "cyrillic", - "cyrillic-ext", - "greek", - "greek-ext", - "latin", - "latin-ext", - "menu", - "vietnamese" - ], - "dateAdded": "2013-01-09" -} diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf deleted file mode 100644 index fbde625d403cc..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf.gz deleted file mode 100644 index ffbf4a965e32c..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf deleted file mode 100644 index 60f7782a2e4ab..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf.gz deleted file mode 100644 index 38c32845ad9a4..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf deleted file mode 100644 index a355c27cde02b..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf.gz deleted file mode 100644 index 9d9d303b98d0e..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf deleted file mode 100644 index 3c9a7a37361b6..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf.gz deleted file mode 100644 index 681577fb32b40..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf deleted file mode 100644 index ff6046d5bfa7c..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf.gz deleted file mode 100644 index 5b29473a7d2c3..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf deleted file mode 100644 index 94c6bcc67e096..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf.gz deleted file mode 100644 index 22d96d0f3f55d..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf deleted file mode 100644 index 04cc002302024..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf.gz deleted file mode 100644 index 03952b1992329..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf deleted file mode 100644 index 39c63d7461796..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf.gz deleted file mode 100644 index 2c62e686f6ac0..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf deleted file mode 100644 index dc743f0a66cf3..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf.gz deleted file mode 100644 index 0d0131bf8acd3..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf deleted file mode 100644 index 8c082c8de0908..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf.gz deleted file mode 100644 index ff39470ca8729..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf deleted file mode 100644 index d69555029c3e1..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf.gz deleted file mode 100644 index 80cca9828edca..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf deleted file mode 100644 index 07172ff666ad2..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf.gz deleted file mode 100644 index 3935ec50be811..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/DESCRIPTION.en_us.html b/homeassistant/components/frontend/www_static/fonts/robotomono/DESCRIPTION.en_us.html deleted file mode 100644 index eb6ba3a2e3cca..0000000000000 --- a/homeassistant/components/frontend/www_static/fonts/robotomono/DESCRIPTION.en_us.html +++ /dev/null @@ -1,17 +0,0 @@ -

    -Roboto Mono is a monospaced addition to the Roboto type family. -Like the other members of the Roboto family, the fonts are optimized for readability on screens across a wide variety of devices and reading environments. -While the monospaced version is related to its variable width cousin, it doesn’t hesitate to change forms to better fit the constraints of a monospaced environment. -For example, narrow glyphs like ‘I’, ‘l’ and ‘i’ have added serifs for more even texture while wider glyphs are adjusted for weight. -Curved caps like ‘C’ and ‘O’ take on the straighter sides from Roboto Condensed. -

    - -

    -Special consideration is given to glyphs important for reading and writing software source code. -Letters with similar shapes are easy to tell apart. -Digit ‘1’, lowercase ‘l’ and capital ‘I’ are easily differentiated as are zero and the letter ‘O’. -Punctuation important for code has also been considered. -For example, the curly braces ‘{ }’ have exaggerated points to clearly differentiate them from parenthesis ‘( )’ and braces ‘[ ]’. -Periods and commas are also exaggerated to identify them more quickly. -The scale and weight of symbols commonly used as operators have also been optimized. -

    diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/LICENSE.txt b/homeassistant/components/frontend/www_static/fonts/robotomono/LICENSE.txt deleted file mode 100644 index d645695673349..0000000000000 --- a/homeassistant/components/frontend/www_static/fonts/robotomono/LICENSE.txt +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/METADATA.json b/homeassistant/components/frontend/www_static/fonts/robotomono/METADATA.json deleted file mode 100644 index a2a212bfa8f06..0000000000000 --- a/homeassistant/components/frontend/www_static/fonts/robotomono/METADATA.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "name": "Roboto Mono", - "designer": "Christian Robertson", - "license": "Apache2", - "visibility": "External", - "category": "Monospace", - "size": 51290, - "fonts": [ - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-Thin", - "fullName": "Roboto Mono Thin", - "style": "normal", - "weight": 100, - "filename": "RobotoMono-Thin.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-ThinItalic", - "fullName": "Roboto Mono Thin Italic", - "style": "italic", - "weight": 100, - "filename": "RobotoMono-ThinItalic.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-Light", - "fullName": "Roboto Mono Light", - "style": "normal", - "weight": 300, - "filename": "RobotoMono-Light.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-LightItalic", - "fullName": "Roboto Mono Light Italic", - "style": "italic", - "weight": 300, - "filename": "RobotoMono-LightItalic.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-Regular", - "fullName": "Roboto Mono", - "style": "normal", - "weight": 400, - "filename": "RobotoMono-Regular.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-Italic", - "fullName": "Roboto Mono Italic", - "style": "italic", - "weight": 400, - "filename": "RobotoMono-Italic.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-Medium", - "fullName": "Roboto Mono Medium", - "style": "normal", - "weight": 500, - "filename": "RobotoMono-Medium.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-MediumItalic", - "fullName": "Roboto Mono Medium Italic", - "style": "italic", - "weight": 500, - "filename": "RobotoMono-MediumItalic.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-Bold", - "fullName": "Roboto Mono Bold", - "style": "normal", - "weight": 700, - "filename": "RobotoMono-Bold.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - }, - { - "name": "Roboto Mono", - "postScriptName": "RobotoMono-BoldItalic", - "fullName": "Roboto Mono Bold Italic", - "style": "italic", - "weight": 700, - "filename": "RobotoMono-BoldItalic.ttf", - "copyright": "Copyright 2015 Google Inc. All Rights Reserved." - } - ], - "subsets": [ - "cyrillic", - "cyrillic-ext", - "greek", - "greek-ext", - "latin", - "latin-ext", - "menu", - "vietnamese" - ], - "dateAdded": "2015-05-13" -} diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf deleted file mode 100644 index c6a81a570c208..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf.gz deleted file mode 100644 index 11e5df422841d..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf deleted file mode 100644 index b2261d6649a28..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf.gz deleted file mode 100644 index 7ce6b8d8f5f48..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf deleted file mode 100644 index 6e4001e196781..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf.gz deleted file mode 100644 index 42e30d27831db..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf deleted file mode 100644 index 5ca4889ebac19..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf.gz deleted file mode 100644 index dd6ed496c7d3e..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf deleted file mode 100644 index db7c368471cf9..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf.gz deleted file mode 100644 index 452274f2a8915..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf deleted file mode 100644 index 0bcdc740c66c5..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf.gz deleted file mode 100644 index d7cccfe5dda86..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf deleted file mode 100644 index b4f5e20e3d955..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf.gz deleted file mode 100644 index 934c7252d33d6..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf deleted file mode 100644 index 495a82ce92ede..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf.gz deleted file mode 100644 index cb043e8fef659..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf deleted file mode 100644 index 1b5085eed8cc3..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf.gz deleted file mode 100644 index 398aac158378c..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf deleted file mode 100644 index dfa1d139ba844..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf.gz deleted file mode 100644 index 1b60ee9dcbb64..0000000000000 Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html deleted file mode 100644 index d6a15a0d610c4..0000000000000 --- a/homeassistant/components/frontend/www_static/frontend.html +++ /dev/null @@ -1,166 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz deleted file mode 100644 index d3312fb091c43..0000000000000 Binary files a/homeassistant/components/frontend/www_static/frontend.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer deleted file mode 160000 index 19187ce518190..0000000000000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 19187ce518190823a8ccc5e7fc3d262cd218fa74 diff --git a/homeassistant/components/frontend/www_static/icons/favicon-1024x1024.png b/homeassistant/components/frontend/www_static/icons/favicon-1024x1024.png deleted file mode 100644 index 4bcc7924726b1..0000000000000 Binary files a/homeassistant/components/frontend/www_static/icons/favicon-1024x1024.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/favicon-192x192.png b/homeassistant/components/frontend/www_static/icons/favicon-192x192.png deleted file mode 100644 index 2959efdf89d84..0000000000000 Binary files a/homeassistant/components/frontend/www_static/icons/favicon-192x192.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/favicon-384x384.png b/homeassistant/components/frontend/www_static/icons/favicon-384x384.png deleted file mode 100644 index 51f6777079007..0000000000000 Binary files a/homeassistant/components/frontend/www_static/icons/favicon-384x384.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/favicon-512x512.png b/homeassistant/components/frontend/www_static/icons/favicon-512x512.png deleted file mode 100644 index 28239a05ad57c..0000000000000 Binary files a/homeassistant/components/frontend/www_static/icons/favicon-512x512.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/favicon-apple-180x180.png b/homeassistant/components/frontend/www_static/icons/favicon-apple-180x180.png deleted file mode 100644 index 20117d00f2275..0000000000000 Binary files a/homeassistant/components/frontend/www_static/icons/favicon-apple-180x180.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/favicon.ico b/homeassistant/components/frontend/www_static/icons/favicon.ico deleted file mode 100644 index 6d12158c18b17..0000000000000 Binary files a/homeassistant/components/frontend/www_static/icons/favicon.ico and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/home-assistant-icon.svg b/homeassistant/components/frontend/www_static/icons/home-assistant-icon.svg deleted file mode 100644 index 1ff4c190f593b..0000000000000 --- a/homeassistant/components/frontend/www_static/icons/home-assistant-icon.svg +++ /dev/null @@ -1,2814 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/homeassistant/components/frontend/www_static/icons/tile-win-150x150.png b/homeassistant/components/frontend/www_static/icons/tile-win-150x150.png deleted file mode 100644 index 20039166df63b..0000000000000 Binary files a/homeassistant/components/frontend/www_static/icons/tile-win-150x150.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/tile-win-310x150.png b/homeassistant/components/frontend/www_static/icons/tile-win-310x150.png deleted file mode 100644 index 6320cb6b21052..0000000000000 Binary files a/homeassistant/components/frontend/www_static/icons/tile-win-310x150.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/tile-win-310x310.png b/homeassistant/components/frontend/www_static/icons/tile-win-310x310.png deleted file mode 100644 index 33bb1223c7570..0000000000000 Binary files a/homeassistant/components/frontend/www_static/icons/tile-win-310x310.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/icons/tile-win-70x70.png b/homeassistant/components/frontend/www_static/icons/tile-win-70x70.png deleted file mode 100644 index 9adf95d56d592..0000000000000 Binary files a/homeassistant/components/frontend/www_static/icons/tile-win-70x70.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/card_media_player_bg.png b/homeassistant/components/frontend/www_static/images/card_media_player_bg.png deleted file mode 100644 index 6c97dd2f511e4..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/card_media_player_bg.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png b/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png deleted file mode 100644 index e62a4165c9bec..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_fitbit_app.png b/homeassistant/components/frontend/www_static/images/config_fitbit_app.png deleted file mode 100644 index 271a0c6dd4798..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_fitbit_app.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_icloud.png b/homeassistant/components/frontend/www_static/images/config_icloud.png deleted file mode 100644 index 2058986018b9f..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_icloud.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_insteon.png b/homeassistant/components/frontend/www_static/images/config_insteon.png deleted file mode 100644 index 0039cf3d160f8..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_insteon.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg b/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg deleted file mode 100644 index f10d258bf34f8..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_webos.png b/homeassistant/components/frontend/www_static/images/config_webos.png deleted file mode 100644 index 757aec76270b5..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_webos.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/config_wink.png b/homeassistant/components/frontend/www_static/images/config_wink.png deleted file mode 100644 index 6b91f8cb58ee8..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/config_wink.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-cloudy.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-cloudy.svg deleted file mode 100644 index a0c80c53611ab..0000000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-cloudy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-fog.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-fog.svg deleted file mode 100644 index 42571dfb73855..0000000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-fog.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-hail.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-hail.svg deleted file mode 100644 index 7934e54f7ae0e..0000000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-hail.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-night.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-night.svg deleted file mode 100644 index d880912be93b1..0000000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-night.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-partlycloudy.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-partlycloudy.svg deleted file mode 100644 index af93dfa0b2a0b..0000000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-partlycloudy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-pouring.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-pouring.svg deleted file mode 100644 index bf20e9bc0c952..0000000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-pouring.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-rainy.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-rainy.svg deleted file mode 100644 index 27ae4d033ffbd..0000000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-rainy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-snowy.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-snowy.svg deleted file mode 100644 index 9c56c2bb46972..0000000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-snowy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-sunny.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-sunny.svg deleted file mode 100644 index 8f9733041a1fe..0000000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-sunny.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/darksky/weather-windy.svg b/homeassistant/components/frontend/www_static/images/darksky/weather-windy.svg deleted file mode 100644 index de0b444fd01f8..0000000000000 --- a/homeassistant/components/frontend/www_static/images/darksky/weather-windy.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png b/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png deleted file mode 100644 index a2cf7f9efef65..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/layers.png b/homeassistant/components/frontend/www_static/images/leaflet/layers.png deleted file mode 100644 index bca0a0e4296b0..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/leaflet/layers.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/leaflet.css b/homeassistant/components/frontend/www_static/images/leaflet/leaflet.css deleted file mode 100644 index a3ea996ead227..0000000000000 --- a/homeassistant/components/frontend/www_static/images/leaflet/leaflet.css +++ /dev/null @@ -1,631 +0,0 @@ -/* required styles */ - -.leaflet-pane, -.leaflet-tile, -.leaflet-marker-icon, -.leaflet-marker-shadow, -.leaflet-tile-container, -.leaflet-pane > svg, -.leaflet-pane > canvas, -.leaflet-zoom-box, -.leaflet-image-layer, -.leaflet-layer { - position: absolute; - left: 0; - top: 0; - } -.leaflet-container { - overflow: hidden; - } -.leaflet-tile, -.leaflet-marker-icon, -.leaflet-marker-shadow { - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - -webkit-user-drag: none; - } -/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ -.leaflet-safari .leaflet-tile { - image-rendering: -webkit-optimize-contrast; - } -/* hack that prevents hw layers "stretching" when loading new tiles */ -.leaflet-safari .leaflet-tile-container { - width: 1600px; - height: 1600px; - -webkit-transform-origin: 0 0; - } -.leaflet-marker-icon, -.leaflet-marker-shadow { - display: block; - } -/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ -/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ -.leaflet-container .leaflet-overlay-pane svg, -.leaflet-container .leaflet-marker-pane img, -.leaflet-container .leaflet-shadow-pane img, -.leaflet-container .leaflet-tile-pane img, -.leaflet-container img.leaflet-image-layer { - max-width: none !important; - } - -.leaflet-container.leaflet-touch-zoom { - -ms-touch-action: pan-x pan-y; - touch-action: pan-x pan-y; - } -.leaflet-container.leaflet-touch-drag { - -ms-touch-action: pinch-zoom; - } -.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { - -ms-touch-action: none; - touch-action: none; -} -.leaflet-container { - -webkit-tap-highlight-color: transparent; -} -.leaflet-container a { - -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); -} -.leaflet-tile { - filter: inherit; - visibility: hidden; - } -.leaflet-tile-loaded { - visibility: inherit; - } -.leaflet-zoom-box { - width: 0; - height: 0; - -moz-box-sizing: border-box; - box-sizing: border-box; - z-index: 800; - } -/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ -.leaflet-overlay-pane svg { - -moz-user-select: none; - } - -.leaflet-pane { z-index: 400; } - -.leaflet-tile-pane { z-index: 200; } -.leaflet-overlay-pane { z-index: 400; } -.leaflet-shadow-pane { z-index: 500; } -.leaflet-marker-pane { z-index: 600; } -.leaflet-tooltip-pane { z-index: 650; } -.leaflet-popup-pane { z-index: 700; } - -.leaflet-map-pane canvas { z-index: 100; } -.leaflet-map-pane svg { z-index: 200; } - -.leaflet-vml-shape { - width: 1px; - height: 1px; - } -.lvml { - behavior: url(#default#VML); - display: inline-block; - position: absolute; - } - - -/* control positioning */ - -.leaflet-control { - position: relative; - z-index: 800; - pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ - pointer-events: auto; - } -.leaflet-top, -.leaflet-bottom { - position: absolute; - z-index: 1000; - pointer-events: none; - } -.leaflet-top { - top: 0; - } -.leaflet-right { - right: 0; - } -.leaflet-bottom { - bottom: 0; - } -.leaflet-left { - left: 0; - } -.leaflet-control { - float: left; - clear: both; - } -.leaflet-right .leaflet-control { - float: right; - } -.leaflet-top .leaflet-control { - margin-top: 10px; - } -.leaflet-bottom .leaflet-control { - margin-bottom: 10px; - } -.leaflet-left .leaflet-control { - margin-left: 10px; - } -.leaflet-right .leaflet-control { - margin-right: 10px; - } - - -/* zoom and fade animations */ - -.leaflet-fade-anim .leaflet-tile { - will-change: opacity; - } -.leaflet-fade-anim .leaflet-popup { - opacity: 0; - -webkit-transition: opacity 0.2s linear; - -moz-transition: opacity 0.2s linear; - -o-transition: opacity 0.2s linear; - transition: opacity 0.2s linear; - } -.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { - opacity: 1; - } -.leaflet-zoom-animated { - -webkit-transform-origin: 0 0; - -ms-transform-origin: 0 0; - transform-origin: 0 0; - } -.leaflet-zoom-anim .leaflet-zoom-animated { - will-change: transform; - } -.leaflet-zoom-anim .leaflet-zoom-animated { - -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); - -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); - -o-transition: -o-transform 0.25s cubic-bezier(0,0,0.25,1); - transition: transform 0.25s cubic-bezier(0,0,0.25,1); - } -.leaflet-zoom-anim .leaflet-tile, -.leaflet-pan-anim .leaflet-tile { - -webkit-transition: none; - -moz-transition: none; - -o-transition: none; - transition: none; - } - -.leaflet-zoom-anim .leaflet-zoom-hide { - visibility: hidden; - } - - -/* cursors */ - -.leaflet-interactive { - cursor: pointer; - } -.leaflet-grab { - cursor: -webkit-grab; - cursor: -moz-grab; - } -.leaflet-crosshair, -.leaflet-crosshair .leaflet-interactive { - cursor: crosshair; - } -.leaflet-popup-pane, -.leaflet-control { - cursor: auto; - } -.leaflet-dragging .leaflet-grab, -.leaflet-dragging .leaflet-grab .leaflet-interactive, -.leaflet-dragging .leaflet-marker-draggable { - cursor: move; - cursor: -webkit-grabbing; - cursor: -moz-grabbing; - } - -/* marker & overlays interactivity */ -.leaflet-marker-icon, -.leaflet-marker-shadow, -.leaflet-image-layer, -.leaflet-pane > svg path, -.leaflet-tile-container { - pointer-events: none; - } - -.leaflet-marker-icon.leaflet-interactive, -.leaflet-image-layer.leaflet-interactive, -.leaflet-pane > svg path.leaflet-interactive { - pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ - pointer-events: auto; - } - -/* visual tweaks */ - -.leaflet-container { - background: #ddd; - outline: 0; - } -.leaflet-container a { - color: #0078A8; - } -.leaflet-container a.leaflet-active { - outline: 2px solid orange; - } -.leaflet-zoom-box { - border: 2px dotted #38f; - background: rgba(255,255,255,0.5); - } - - -/* general typography */ -.leaflet-container { - font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; - } - - -/* general toolbar styles */ - -.leaflet-bar { - box-shadow: 0 1px 5px rgba(0,0,0,0.65); - border-radius: 4px; - } -.leaflet-bar a, -.leaflet-bar a:hover { - background-color: #fff; - border-bottom: 1px solid #ccc; - width: 26px; - height: 26px; - line-height: 26px; - display: block; - text-align: center; - text-decoration: none; - color: black; - } -.leaflet-bar a, -.leaflet-control-layers-toggle { - background-position: 50% 50%; - background-repeat: no-repeat; - display: block; - } -.leaflet-bar a:hover { - background-color: #f4f4f4; - } -.leaflet-bar a:first-child { - border-top-left-radius: 4px; - border-top-right-radius: 4px; - } -.leaflet-bar a:last-child { - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - border-bottom: none; - } -.leaflet-bar a.leaflet-disabled { - cursor: default; - background-color: #f4f4f4; - color: #bbb; - } - -.leaflet-touch .leaflet-bar a { - width: 30px; - height: 30px; - line-height: 30px; - } -.leaflet-touch .leaflet-bar a:first-child { - border-top-left-radius: 2px; - border-top-right-radius: 2px; - } -.leaflet-touch .leaflet-bar a:last-child { - border-bottom-left-radius: 2px; - border-bottom-right-radius: 2px; - } - -/* zoom control */ - -.leaflet-control-zoom-in, -.leaflet-control-zoom-out { - font: bold 18px 'Lucida Console', Monaco, monospace; - text-indent: 1px; - } - -.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { - font-size: 22px; - } - - -/* layers control */ - -.leaflet-control-layers { - box-shadow: 0 1px 5px rgba(0,0,0,0.4); - background: #fff; - border-radius: 5px; - } -.leaflet-control-layers-toggle { - background-image: url(images/layers.png); - width: 36px; - height: 36px; - } -.leaflet-retina .leaflet-control-layers-toggle { - background-image: url(images/layers-2x.png); - background-size: 26px 26px; - } -.leaflet-touch .leaflet-control-layers-toggle { - width: 44px; - height: 44px; - } -.leaflet-control-layers .leaflet-control-layers-list, -.leaflet-control-layers-expanded .leaflet-control-layers-toggle { - display: none; - } -.leaflet-control-layers-expanded .leaflet-control-layers-list { - display: block; - position: relative; - } -.leaflet-control-layers-expanded { - padding: 6px 10px 6px 6px; - color: #333; - background: #fff; - } -.leaflet-control-layers-scrollbar { - overflow-y: scroll; - padding-right: 5px; - } -.leaflet-control-layers-selector { - margin-top: 2px; - position: relative; - top: 1px; - } -.leaflet-control-layers label { - display: block; - } -.leaflet-control-layers-separator { - height: 0; - border-top: 1px solid #ddd; - margin: 5px -10px 5px -6px; - } - -/* Default icon URLs */ -.leaflet-default-icon-path { - background-image: url(images/marker-icon.png); - } - - -/* attribution and scale controls */ - -.leaflet-container .leaflet-control-attribution { - background: #fff; - background: rgba(255, 255, 255, 0.7); - margin: 0; - } -.leaflet-control-attribution, -.leaflet-control-scale-line { - padding: 0 5px; - color: #333; - } -.leaflet-control-attribution a { - text-decoration: none; - } -.leaflet-control-attribution a:hover { - text-decoration: underline; - } -.leaflet-container .leaflet-control-attribution, -.leaflet-container .leaflet-control-scale { - font-size: 11px; - } -.leaflet-left .leaflet-control-scale { - margin-left: 5px; - } -.leaflet-bottom .leaflet-control-scale { - margin-bottom: 5px; - } -.leaflet-control-scale-line { - border: 2px solid #777; - border-top: none; - line-height: 1.1; - padding: 2px 5px 1px; - font-size: 11px; - white-space: nowrap; - overflow: hidden; - -moz-box-sizing: border-box; - box-sizing: border-box; - - background: #fff; - background: rgba(255, 255, 255, 0.5); - } -.leaflet-control-scale-line:not(:first-child) { - border-top: 2px solid #777; - border-bottom: none; - margin-top: -2px; - } -.leaflet-control-scale-line:not(:first-child):not(:last-child) { - border-bottom: 2px solid #777; - } - -.leaflet-touch .leaflet-control-attribution, -.leaflet-touch .leaflet-control-layers, -.leaflet-touch .leaflet-bar { - box-shadow: none; - } -.leaflet-touch .leaflet-control-layers, -.leaflet-touch .leaflet-bar { - border: 2px solid rgba(0,0,0,0.2); - background-clip: padding-box; - } - - -/* popup */ - -.leaflet-popup { - position: absolute; - text-align: center; - margin-bottom: 20px; - } -.leaflet-popup-content-wrapper { - padding: 1px; - text-align: left; - border-radius: 12px; - } -.leaflet-popup-content { - margin: 13px 19px; - line-height: 1.4; - } -.leaflet-popup-content p { - margin: 18px 0; - } -.leaflet-popup-tip-container { - width: 40px; - height: 20px; - position: absolute; - left: 50%; - margin-left: -20px; - overflow: hidden; - pointer-events: none; - } -.leaflet-popup-tip { - width: 17px; - height: 17px; - padding: 1px; - - margin: -10px auto 0; - - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -ms-transform: rotate(45deg); - -o-transform: rotate(45deg); - transform: rotate(45deg); - } -.leaflet-popup-content-wrapper, -.leaflet-popup-tip { - background: white; - color: #333; - box-shadow: 0 3px 14px rgba(0,0,0,0.4); - } -.leaflet-container a.leaflet-popup-close-button { - position: absolute; - top: 0; - right: 0; - padding: 4px 4px 0 0; - border: none; - text-align: center; - width: 18px; - height: 14px; - font: 16px/14px Tahoma, Verdana, sans-serif; - color: #c3c3c3; - text-decoration: none; - font-weight: bold; - background: transparent; - } -.leaflet-container a.leaflet-popup-close-button:hover { - color: #999; - } -.leaflet-popup-scrolled { - overflow: auto; - border-bottom: 1px solid #ddd; - border-top: 1px solid #ddd; - } - -.leaflet-oldie .leaflet-popup-content-wrapper { - zoom: 1; - } -.leaflet-oldie .leaflet-popup-tip { - width: 24px; - margin: 0 auto; - - -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; - filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); - } -.leaflet-oldie .leaflet-popup-tip-container { - margin-top: -1px; - } - -.leaflet-oldie .leaflet-control-zoom, -.leaflet-oldie .leaflet-control-layers, -.leaflet-oldie .leaflet-popup-content-wrapper, -.leaflet-oldie .leaflet-popup-tip { - border: 1px solid #999; - } - - -/* div icon */ - -.leaflet-div-icon { - background: #fff; - border: 1px solid #666; - } - - -/* Tooltip */ -/* Base styles for the element that has a tooltip */ -.leaflet-tooltip { - position: absolute; - padding: 6px; - background-color: #fff; - border: 1px solid #fff; - border-radius: 3px; - color: #222; - white-space: nowrap; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - pointer-events: none; - box-shadow: 0 1px 3px rgba(0,0,0,0.4); - } -.leaflet-tooltip.leaflet-clickable { - cursor: pointer; - pointer-events: auto; - } -.leaflet-tooltip-top:before, -.leaflet-tooltip-bottom:before, -.leaflet-tooltip-left:before, -.leaflet-tooltip-right:before { - position: absolute; - pointer-events: none; - border: 6px solid transparent; - background: transparent; - content: ""; - } - -/* Directions */ - -.leaflet-tooltip-bottom { - margin-top: 6px; -} -.leaflet-tooltip-top { - margin-top: -6px; -} -.leaflet-tooltip-bottom:before, -.leaflet-tooltip-top:before { - left: 50%; - margin-left: -6px; - } -.leaflet-tooltip-top:before { - bottom: 0; - margin-bottom: -12px; - border-top-color: #fff; - } -.leaflet-tooltip-bottom:before { - top: 0; - margin-top: -12px; - margin-left: -6px; - border-bottom-color: #fff; - } -.leaflet-tooltip-left { - margin-left: -6px; -} -.leaflet-tooltip-right { - margin-left: 6px; -} -.leaflet-tooltip-left:before, -.leaflet-tooltip-right:before { - top: 50%; - margin-top: -6px; - } -.leaflet-tooltip-left:before { - right: 0; - margin-right: -12px; - border-left-color: #fff; - } -.leaflet-tooltip-right:before { - left: 0; - margin-left: -12px; - border-right-color: #fff; - } diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png deleted file mode 100644 index 0015b6495fa45..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png deleted file mode 100644 index e2e9f757f515d..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png deleted file mode 100644 index d1e773c715a9b..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/logo_automatic.png b/homeassistant/components/frontend/www_static/images/logo_automatic.png deleted file mode 100644 index ab03fa93b4c6c..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/logo_automatic.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/logo_axis.png b/homeassistant/components/frontend/www_static/images/logo_axis.png deleted file mode 100644 index 5eeb9b7b2a78f..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/logo_axis.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/logo_philips_hue.png b/homeassistant/components/frontend/www_static/images/logo_philips_hue.png deleted file mode 100644 index ae4df811fa8d1..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/logo_philips_hue.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/logo_plex_mediaserver.png b/homeassistant/components/frontend/www_static/images/logo_plex_mediaserver.png deleted file mode 100644 index 97a1b4b352cdb..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/logo_plex_mediaserver.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/logo_tellduslive.png b/homeassistant/components/frontend/www_static/images/logo_tellduslive.png new file mode 100644 index 0000000000000..7ea78f8ef3aad Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/logo_tellduslive.png differ diff --git a/homeassistant/components/frontend/www_static/images/notification-badge.png b/homeassistant/components/frontend/www_static/images/notification-badge.png deleted file mode 100644 index 2d254444915e9..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/notification-badge.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/images/smart-tv.png b/homeassistant/components/frontend/www_static/images/smart-tv.png deleted file mode 100644 index 5ecda68b40290..0000000000000 Binary files a/homeassistant/components/frontend/www_static/images/smart-tv.png and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/mdi.html b/homeassistant/components/frontend/www_static/mdi.html deleted file mode 100644 index 0be740d88b303..0000000000000 --- a/homeassistant/components/frontend/www_static/mdi.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/mdi.html.gz b/homeassistant/components/frontend/www_static/mdi.html.gz deleted file mode 100644 index 8203d51e8553d..0000000000000 Binary files a/homeassistant/components/frontend/www_static/mdi.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/micromarkdown-js.html b/homeassistant/components/frontend/www_static/micromarkdown-js.html deleted file mode 100644 index a80c564cb7b51..0000000000000 --- a/homeassistant/components/frontend/www_static/micromarkdown-js.html +++ /dev/null @@ -1,10 +0,0 @@ - diff --git a/homeassistant/components/frontend/www_static/micromarkdown-js.html.gz b/homeassistant/components/frontend/www_static/micromarkdown-js.html.gz deleted file mode 100644 index 341f96c260ec5..0000000000000 Binary files a/homeassistant/components/frontend/www_static/micromarkdown-js.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html deleted file mode 100644 index 66f9e2c8fbc71..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html +++ /dev/null @@ -1,7 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz deleted file mode 100644 index 08a7f5002cd0f..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html deleted file mode 100644 index e32d8306061cf..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz deleted file mode 100644 index 18406f9cad6b8..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html deleted file mode 100644 index 47b283e0a513c..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html +++ /dev/null @@ -1,2 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz deleted file mode 100644 index d534814718118..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-mqtt.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-mqtt.html deleted file mode 100644 index 80201efa386c6..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-mqtt.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-mqtt.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-mqtt.html.gz deleted file mode 100644 index 28a28a9647dba..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-mqtt.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html deleted file mode 100644 index c65c92d1b4f1a..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz deleted file mode 100644 index 9a5e3896a8281..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html deleted file mode 100644 index 5f4337b017109..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz deleted file mode 100644 index 686dd7b7cc298..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html deleted file mode 100644 index 53638dd582b07..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html +++ /dev/null @@ -1,2 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz deleted file mode 100644 index 24fd95f17a7b7..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html deleted file mode 100644 index 68bcffbb13dd4..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz deleted file mode 100644 index 4c6d52d644842..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-hassio.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html deleted file mode 100644 index 3b5f128b76303..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz deleted file mode 100644 index f4e4ce09f4130..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html deleted file mode 100644 index 68bd07459e74a..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz deleted file mode 100644 index 949d08e067413..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-kiosk.html b/homeassistant/components/frontend/www_static/panels/ha-panel-kiosk.html deleted file mode 100644 index 803c3696726fe..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-kiosk.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-kiosk.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-kiosk.html.gz deleted file mode 100644 index 6327968686624..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-kiosk.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html deleted file mode 100644 index fc9aa01d0b1cd..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz deleted file mode 100644 index 904aecb6acb36..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-mailbox.html b/homeassistant/components/frontend/www_static/panels/ha-panel-mailbox.html deleted file mode 100644 index 62948d65f07cd..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-mailbox.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-mailbox.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-mailbox.html.gz deleted file mode 100644 index d96d49785acf3..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-mailbox.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html deleted file mode 100644 index 5f34f7bc28a62..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz deleted file mode 100644 index d9dd4c687fbc9..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html b/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html deleted file mode 100644 index 35954d7dc5f09..0000000000000 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html.gz deleted file mode 100644 index 4af82c430637d..0000000000000 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/robots.txt b/homeassistant/components/frontend/www_static/robots.txt deleted file mode 100644 index 77470cb39f05f..0000000000000 --- a/homeassistant/components/frontend/www_static/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: / \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js deleted file mode 100644 index dc4770853e0b8..0000000000000 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ /dev/null @@ -1,346 +0,0 @@ -/** - * Copyright 2016 Google Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. -*/ - -// DO NOT EDIT THIS GENERATED OUTPUT DIRECTLY! -// This file should be overwritten as part of your build process. -// If you need to extend the behavior of the generated service worker, the best approach is to write -// additional code and include it using the importScripts option: -// https://github.com/GoogleChrome/sw-precache#importscripts-arraystring -// -// Alternatively, it's possible to make changes to the underlying template file and then use that as the -// new base for generating output, via the templateFilePath option: -// https://github.com/GoogleChrome/sw-precache#templatefilepath-string -// -// If you go that route, make sure that whenever you update your sw-precache dependency, you reconcile any -// changes made to this original template file with your modified copy. - -// This generated service worker JavaScript will precache your site's resources. -// The code needs to be saved in a .js file at the top-level of your site, and registered -// from your pages in order to be used. See -// https://github.com/googlechrome/sw-precache/blob/master/demo/app/js/service-worker-registration.js -// for an example of how you can register this script and handle various service worker events. - -/* eslint-env worker, serviceworker */ -/* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ -'use strict'; - -var precacheConfig = [["/","eceffe0debe81636e1eb8604e6eefbd6"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-c04709d3517dd3fd34b2f7d6bba6ec8e.html","e072f7bbe595bcb104d117a45592459d"],["/static/mdi-89074face5529f5fe6fbae49ecb3e88b.html","97754e463f9e56a95c813d4d8e792347"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; -var cacheName = 'sw-precache-v3--' + (self.registration ? self.registration.scope : ''); - - -var ignoreUrlParametersMatching = [/^utm_/]; - - - -var addDirectoryIndex = function (originalUrl, index) { - var url = new URL(originalUrl); - if (url.pathname.slice(-1) === '/') { - url.pathname += index; - } - return url.toString(); - }; - -var cleanResponse = function (originalResponse) { - // If this is not a redirected response, then we don't have to do anything. - if (!originalResponse.redirected) { - return Promise.resolve(originalResponse); - } - - // Firefox 50 and below doesn't support the Response.body stream, so we may - // need to read the entire body to memory as a Blob. - var bodyPromise = 'body' in originalResponse ? - Promise.resolve(originalResponse.body) : - originalResponse.blob(); - - return bodyPromise.then(function(body) { - // new Response() is happy when passed either a stream or a Blob. - return new Response(body, { - headers: originalResponse.headers, - status: originalResponse.status, - statusText: originalResponse.statusText - }); - }); - }; - -var createCacheKey = function (originalUrl, paramName, paramValue, - dontCacheBustUrlsMatching) { - // Create a new URL object to avoid modifying originalUrl. - var url = new URL(originalUrl); - - // If dontCacheBustUrlsMatching is not set, or if we don't have a match, - // then add in the extra cache-busting URL parameter. - if (!dontCacheBustUrlsMatching || - !(url.pathname.match(dontCacheBustUrlsMatching))) { - url.search += (url.search ? '&' : '') + - encodeURIComponent(paramName) + '=' + encodeURIComponent(paramValue); - } - - return url.toString(); - }; - -var isPathWhitelisted = function (whitelist, absoluteUrlString) { - // If the whitelist is empty, then consider all URLs to be whitelisted. - if (whitelist.length === 0) { - return true; - } - - // Otherwise compare each path regex to the path of the URL passed in. - var path = (new URL(absoluteUrlString)).pathname; - return whitelist.some(function(whitelistedPathRegex) { - return path.match(whitelistedPathRegex); - }); - }; - -var stripIgnoredUrlParameters = function (originalUrl, - ignoreUrlParametersMatching) { - var url = new URL(originalUrl); - // Remove the hash; see https://github.com/GoogleChrome/sw-precache/issues/290 - url.hash = ''; - - url.search = url.search.slice(1) // Exclude initial '?' - .split('&') // Split into an array of 'key=value' strings - .map(function(kv) { - return kv.split('='); // Split each 'key=value' string into a [key, value] array - }) - .filter(function(kv) { - return ignoreUrlParametersMatching.every(function(ignoredRegex) { - return !ignoredRegex.test(kv[0]); // Return true iff the key doesn't match any of the regexes. - }); - }) - .map(function(kv) { - return kv.join('='); // Join each [key, value] array into a 'key=value' string - }) - .join('&'); // Join the array of 'key=value' strings into a string with '&' in between each - - return url.toString(); - }; - - -var hashParamName = '_sw-precache'; -var urlsToCacheKeys = new Map( - precacheConfig.map(function(item) { - var relativeUrl = item[0]; - var hash = item[1]; - var absoluteUrl = new URL(relativeUrl, self.location); - var cacheKey = createCacheKey(absoluteUrl, hashParamName, hash, false); - return [absoluteUrl.toString(), cacheKey]; - }) -); - -function setOfCachedUrls(cache) { - return cache.keys().then(function(requests) { - return requests.map(function(request) { - return request.url; - }); - }).then(function(urls) { - return new Set(urls); - }); -} - -self.addEventListener('install', function(event) { - event.waitUntil( - caches.open(cacheName).then(function(cache) { - return setOfCachedUrls(cache).then(function(cachedUrls) { - return Promise.all( - Array.from(urlsToCacheKeys.values()).map(function(cacheKey) { - // If we don't have a key matching url in the cache already, add it. - if (!cachedUrls.has(cacheKey)) { - var request = new Request(cacheKey, {credentials: 'same-origin'}); - return fetch(request).then(function(response) { - // Bail out of installation unless we get back a 200 OK for - // every request. - if (!response.ok) { - throw new Error('Request for ' + cacheKey + ' returned a ' + - 'response with status ' + response.status); - } - - return cleanResponse(response).then(function(responseToCache) { - return cache.put(cacheKey, responseToCache); - }); - }); - } - }) - ); - }); - }).then(function() { - - // Force the SW to transition from installing -> active state - return self.skipWaiting(); - - }) - ); -}); - -self.addEventListener('activate', function(event) { - var setOfExpectedUrls = new Set(urlsToCacheKeys.values()); - - event.waitUntil( - caches.open(cacheName).then(function(cache) { - return cache.keys().then(function(existingRequests) { - return Promise.all( - existingRequests.map(function(existingRequest) { - if (!setOfExpectedUrls.has(existingRequest.url)) { - return cache.delete(existingRequest); - } - }) - ); - }); - }).then(function() { - - return self.clients.claim(); - - }) - ); -}); - - -self.addEventListener('fetch', function(event) { - if (event.request.method === 'GET') { - // Should we call event.respondWith() inside this fetch event handler? - // This needs to be determined synchronously, which will give other fetch - // handlers a chance to handle the request if need be. - var shouldRespond; - - // First, remove all the ignored parameters and hash fragment, and see if we - // have that URL in our cache. If so, great! shouldRespond will be true. - var url = stripIgnoredUrlParameters(event.request.url, ignoreUrlParametersMatching); - shouldRespond = urlsToCacheKeys.has(url); - - // If shouldRespond is false, check again, this time with 'index.html' - // (or whatever the directoryIndex option is set to) at the end. - var directoryIndex = 'index.html'; - if (!shouldRespond && directoryIndex) { - url = addDirectoryIndex(url, directoryIndex); - shouldRespond = urlsToCacheKeys.has(url); - } - - // If shouldRespond is still false, check to see if this is a navigation - // request, and if so, whether the URL matches navigateFallbackWhitelist. - var navigateFallback = '/'; - if (!shouldRespond && - navigateFallback && - (event.request.mode === 'navigate') && - isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"], event.request.url)) { - url = new URL(navigateFallback, self.location).toString(); - shouldRespond = urlsToCacheKeys.has(url); - } - - // If shouldRespond was set to true at any point, then call - // event.respondWith(), using the appropriate cache key. - if (shouldRespond) { - event.respondWith( - caches.open(cacheName).then(function(cache) { - return cache.match(urlsToCacheKeys.get(url)).then(function(response) { - if (response) { - return response; - } - throw Error('The cached response that was expected is missing.'); - }); - }).catch(function(e) { - // Fall back to just fetch()ing the request if some unexpected error - // prevented the cached response from being valid. - console.warn('Couldn\'t serve response for "%s" from cache: %O', event.request.url, e); - return fetch(event.request); - }) - ); - } - } -}); - - - - - - - - -self.addEventListener("push", function(event) { - var data; - if (event.data) { - data = event.data.json(); - event.waitUntil( - self.registration.showNotification(data.title, data) - .then(function(notification){ - firePushCallback({ - type: "received", - tag: data.tag, - data: data.data - }, data.data.jwt); - }) - ); - } -}); -self.addEventListener('notificationclick', function(event) { - var url; - - notificationEventCallback('clicked', event); - - event.notification.close(); - - if (!event.notification.data || !event.notification.data.url) { - return; - } - - url = event.notification.data.url; - - if (!url) return; - - event.waitUntil( - clients.matchAll({ - type: 'window', - }) - .then(function (windowClients) { - var i; - var client; - for (i = 0; i < windowClients.length; i++) { - client = windowClients[i]; - if (client.url === url && 'focus' in client) { - return client.focus(); - } - } - if (clients.openWindow) { - return clients.openWindow(url); - } - return undefined; - }) - ); -}); -self.addEventListener('notificationclose', function(event) { - notificationEventCallback('closed', event); -}); - -function notificationEventCallback(event_type, event){ - firePushCallback({ - action: event.action, - data: event.notification.data, - tag: event.notification.tag, - type: event_type - }, event.notification.data.jwt); -} -function firePushCallback(payload, jwt){ - // Don't send the JWT in the payload.data - delete payload.data.jwt; - // If payload.data is empty then just remove the entire payload.data object. - if (Object.keys(payload.data).length === 0 && payload.data.constructor === Object) { - delete payload.data; - } - fetch('/api/notify.html5/callback', { - method: 'POST', - headers: new Headers({'Content-Type': 'application/json', - 'Authorization': 'Bearer '+jwt}), - body: JSON.stringify(payload) - }); -} diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz deleted file mode 100644 index 14edb98db2b16..0000000000000 Binary files a/homeassistant/components/frontend/www_static/service_worker.js.gz and /dev/null differ diff --git a/homeassistant/components/frontend/www_static/webcomponents-lite.js b/homeassistant/components/frontend/www_static/webcomponents-lite.js deleted file mode 100644 index 7f2e16c7c2920..0000000000000 --- a/homeassistant/components/frontend/www_static/webcomponents-lite.js +++ /dev/null @@ -1,191 +0,0 @@ -(function(){/* - - Copyright (c) 2016 The Polymer Project Authors. All rights reserved. - This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt - The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt - The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt - Code distributed by Google as part of the polymer project is also - subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt - - Copyright (c) 2014 The Polymer Project Authors. All rights reserved. - This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt - The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt - The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt - Code distributed by Google as part of the polymer project is also - subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt - -Copyright (c) 2016 The Polymer Project Authors. All rights reserved. -This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt -The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt -The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt -Code distributed by Google as part of the polymer project is also -subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt - -Copyright (c) 2017 The Polymer Project Authors. All rights reserved. -This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt -The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt -The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt -Code distributed by Google as part of the polymer project is also -subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt -*/ -'use strict';var nb="undefined"!=typeof window&&window===this?this:"undefined"!=typeof global&&null!=global?global:this; -(function(){function k(){var a=this;this.s={};this.g=document.documentElement;var b=new za;b.rules=[];this.h=t.set(this.g,new t(b));this.i=!1;this.b=this.a=null;ob(function(){a.c()})}function H(){this.customStyles=[];this.enqueued=!1}function pb(){}function ha(a){this.cache={};this.c=void 0===a?100:a}function p(){}function t(a,b,c,d,e){this.G=a||null;this.b=b||null;this.sa=c||[];this.P=null;this.Y=e||"";this.a=this.B=this.K=null}function r(){}function za(){this.end=this.start=0;this.rules=this.parent= -this.previous=null;this.cssText=this.parsedCssText="";this.atRule=!1;this.type=0;this.parsedSelector=this.selector=this.keyframesName=""}function $c(a){function b(b,c){Object.defineProperty(b,"innerHTML",{enumerable:c.enumerable,configurable:!0,get:c.get,set:function(b){var d=this,e=void 0;m(this)&&(e=[],J(this,function(a){a!==d&&e.push(a)}));c.set.call(this,b);if(e)for(var f=0;f":return">";case '"':return""";case "\u00a0":return" "}}function Kb(a){for(var b={},c=0;c";break a;case Node.TEXT_NODE:h=h.data;h=n&&vd[n.localName]?h:h.replace(wd,Jb);break a;case Node.COMMENT_NODE:h="\x3c!--"+h.data+"--\x3e";break a;default:throw window.console.error(h),Error("not implemented");}}c+=h}return c}function U(a){F.currentNode=a;return F.parentNode()}function Ha(a){F.currentNode= -a;return F.firstChild()}function Ia(a){F.currentNode=a;return F.lastChild()}function Lb(a){F.currentNode=a;return F.previousSibling()}function Mb(a){F.currentNode=a;return F.nextSibling()}function S(a){var b=[];F.currentNode=a;for(a=F.firstChild();a;)b.push(a),a=F.nextSibling();return b}function Nb(a){x.currentNode=a;return x.parentNode()}function Ob(a){x.currentNode=a;return x.firstChild()}function Pb(a){x.currentNode=a;return x.lastChild()}function Qb(a){x.currentNode=a;return x.previousSibling()} -function Rb(a){x.currentNode=a;return x.nextSibling()}function Sb(a){var b=[];x.currentNode=a;for(a=x.firstChild();a;)b.push(a),a=x.nextSibling();return b}function Tb(a){return Oa(a,function(a){return S(a)})}function Ub(a){switch(a.nodeType){case Node.ELEMENT_NODE:case Node.DOCUMENT_FRAGMENT_NODE:a=document.createTreeWalker(a,NodeFilter.SHOW_TEXT,null,!1);for(var b="",c;c=a.nextNode();)b+=c.nodeValue;return b;default:return a.nodeValue}}function M(a,b,c){for(var d in b){var e=Object.getOwnPropertyDescriptor(a, -d);e&&e.configurable||!e&&c?Object.defineProperty(a,d,b[d]):c&&console.warn("Could not define",d,"on",a)}}function N(a){M(a,Vb);M(a,Pa);M(a,Qa)}function Wb(a,b,c){Fb(a);c=c||null;a.__shady=a.__shady||{};b.__shady=b.__shady||{};c&&(c.__shady=c.__shady||{});a.__shady.previousSibling=c?c.__shady.previousSibling:b.lastChild;var d=a.__shady.previousSibling;d&&d.__shady&&(d.__shady.nextSibling=a);(d=a.__shady.nextSibling=c)&&d.__shady&&(d.__shady.previousSibling=a);a.__shady.parentNode=b;c?c===b.__shady.firstChild&& -(b.__shady.firstChild=a):(b.__shady.lastChild=a,b.__shady.firstChild||(b.__shady.firstChild=a));b.__shady.childNodes=null}function Ra(a,b,c){if(b===a)throw Error("Failed to execute 'appendChild' on 'Node': The new child element contains the parent.");if(c){var d=c.__shady&&c.__shady.parentNode;if(void 0!==d&&d!==a||void 0===d&&U(c)!==a)throw Error("Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.");}if(c===b)return b;b.parentNode&& -Sa(b.parentNode,b);d=Z(a);var e;if(e=d)a:{if(!b.__noInsertionPoint){var f;"slot"===b.localName?f=[b]:b.querySelectorAll&&(f=b.querySelectorAll("slot"));if(f&&f.length){e=f;break a}}e=void 0}f=e;d&&("slot"===a.localName||f)&&d.M();if(T(a)){e=c;Eb(a);a.__shady=a.__shady||{};void 0!==a.__shady.firstChild&&(a.__shady.childNodes=null);if(b.nodeType===Node.DOCUMENT_FRAGMENT_NODE){for(var g=b.childNodes,h=0;h":return">";case "\u00a0":return" "}},h=function(b){Object.defineProperty(b,"innerHTML",{get:function(){for(var a="",b=this.content.firstChild;b;b=b.nextSibling)a+=b.outerHTML||b.data.replace(t,g);return a},set:function(b){k.body.innerHTML=b;for(a.b(k);this.content.firstChild;)this.content.removeChild(this.content.firstChild);for(;k.body.firstChild;)this.content.appendChild(k.body.firstChild)},configurable:!0})},k=document.implementation.createHTMLDocument("template"), -l=!0,m=document.createElement("style");m.textContent="template{display:none;}";var p=document.head;p.insertBefore(m,p.firstElementChild);a.prototype=Object.create(HTMLElement.prototype);var r=!document.createElement("div").hasOwnProperty("innerHTML");a.O=function(b){if(!b.content){b.content=k.createDocumentFragment();for(var c;c=b.firstChild;)b.content.appendChild(c);if(r)b.__proto__=a.prototype;else if(b.cloneNode=function(b){return a.a(this,b)},l)try{h(b)}catch(y){l=!1}a.b(b.content)}};h(a.prototype); -a.b=function(b){b=b.querySelectorAll("template");for(var c=0,d=b.length,e;c]/g}if(b||f)a.a=function(a,b){var d=c.call(a,!1);this.O&&this.O(d);b&&(d.content.appendChild(c.call(a.content,!0)),this.ra(d.content,a.content));return d},a.prototype.cloneNode=function(b){return a.a(this, -b)},a.ra=function(a,b){if(b.querySelectorAll){b=b.querySelectorAll("template");a=a.querySelectorAll("template");for(var c=0,d=a.length,e,f;c]*)(rel=['|"]?stylesheet['|"]?[^>]*>)/g,y={nb:function(a,b){a.href&&a.setAttribute("href",y.ua(a.getAttribute("href"),b));a.src&&a.setAttribute("src",y.ua(a.getAttribute("src"),b)); -if("style"===a.localName){var c=y.Ma(a.textContent,b,r);a.textContent=y.Ma(c,b,t)}},Ma:function(a,b,c){return a.replace(c,function(a,c,d,e){a=d.replace(/["']/g,"");b&&(a=y.Na(a,b));return c+"'"+a+"'"+e})},ua:function(a,b){return a&&q.test(a)?a:y.Na(a,b)},Na:function(a,b){if(void 0===y.ma){y.ma=!1;try{var c=new URL("b","http://a");c.pathname="c%20d";y.ma="http://a/c%20d"===c.href}catch(Lc){}}if(y.ma)return(new URL(a,b)).href;c=y.Za;c||(c=document.implementation.createHTMLDocument("temp"),y.Za=c,c.xa= -c.createElement("base"),c.head.appendChild(c.xa),c.wa=c.createElement("a"));c.xa.href=b;c.wa.href=a;return c.wa.href||a}},w={async:!0,load:function(a,b,c){if(a)if(a.match(/^data:/)){a=a.split(",");var d=a[1];d=-1e.status?b(d,a):c(d)};e.send()}else c("error: href must be specified")}},v=/Trident/.test(navigator.userAgent)||/Edge\/\d./i.test(navigator.userAgent);k.prototype.c=function(a){var b=this;a=a.querySelectorAll("link[rel=import]");l(a,function(a){return b.h(a)})};k.prototype.h=function(a){var b=this,c=a.href;if(void 0!==this.a[c]){var d=this.a[c];d&&d.__loaded&&(a.import=d,this.g(a))}else this.b++,this.a[c]="pending",w.load(c,function(a,d){a=b.s(a,d||c); -b.a[c]=a;b.b--;b.c(a);b.i()},function(){b.a[c]=null;b.b--;b.i()})};k.prototype.s=function(a,b){if(!a)return document.createDocumentFragment();v&&(a=a.replace(x,function(a,b,c){return-1===a.indexOf("type=")?b+" type=import-disable "+c:a}));var c=document.createElement("template");c.innerHTML=a;if(c.content)a=c.content;else for(a=document.createDocumentFragment();c.firstChild;)a.appendChild(c.firstChild);if(c=a.querySelector("base"))b=y.ua(c.getAttribute("href"),b),c.removeAttribute("href");c=a.querySelectorAll('link[rel=import], link[rel=stylesheet][href][type=import-disable],\n style:not([type]), link[rel=stylesheet][href]:not([type]),\n script:not([type]), script[type="application/javascript"],\n script[type="text/javascript"]'); -var d=0;l(c,function(a){g(a);y.nb(a,b);a.setAttribute("import-dependency","");"script"===a.localName&&!a.src&&a.textContent&&(a.setAttribute("src","data:text/javascript;charset=utf-8,"+encodeURIComponent(a.textContent+("\n//# sourceURL="+b+(d?"-"+d:"")+".js\n"))),a.textContent="",d++)});return a};k.prototype.i=function(){var a=this;if(!this.b){this.f.disconnect();this.flatten(document);var b=!1,c=!1,d=function(){c&&b&&(a.c(document),a.b||(a.f.observe(document.head,{childList:!0,subtree:!0}),a.j()))}; -this.v(function(){c=!0;d()});this.u(function(){b=!0;d()})}};k.prototype.flatten=function(a){var b=this;a=a.querySelectorAll("link[rel=import]");l(a,function(a){var c=b.a[a.href];(a.import=c)&&c.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&(b.a[a.href]=a,a.readyState="loading",a.import=a,b.flatten(c),a.appendChild(c))})};k.prototype.u=function(a){function b(e){if(e]/g,ud=Kb("area base br col command embed hr img input keygen link meta param source track wbr".split(" ")),vd=Kb("style script xmp iframe noembed noframes plaintext noscript".split(" ")),F=document.createTreeWalker(document, -NodeFilter.SHOW_ALL,null,!1),x=document.createTreeWalker(document,NodeFilter.SHOW_ELEMENT,null,!1),Rd=Object.freeze({parentNode:U,firstChild:Ha,lastChild:Ia,previousSibling:Lb,nextSibling:Mb,childNodes:S,parentElement:Nb,firstElementChild:Ob,lastElementChild:Pb,previousElementSibling:Qb,nextElementSibling:Rb,children:Sb,innerHTML:Tb,textContent:Ub}),hb=Object.getOwnPropertyDescriptor(Element.prototype,"innerHTML")||Object.getOwnPropertyDescriptor(HTMLElement.prototype,"innerHTML"),ta=document.implementation.createHTMLDocument("inert").createElement("div"), -ib=Object.getOwnPropertyDescriptor(Document.prototype,"activeElement"),Vb={parentElement:{get:function(){var a=this.__shady&&this.__shady.parentNode;a&&a.nodeType!==Node.ELEMENT_NODE&&(a=null);return void 0!==a?a:Nb(this)},configurable:!0},parentNode:{get:function(){var a=this.__shady&&this.__shady.parentNode;return void 0!==a?a:U(this)},configurable:!0},nextSibling:{get:function(){var a=this.__shady&&this.__shady.nextSibling;return void 0!==a?a:Mb(this)},configurable:!0},previousSibling:{get:function(){var a= -this.__shady&&this.__shady.previousSibling;return void 0!==a?a:Lb(this)},configurable:!0},className:{get:function(){return this.getAttribute("class")||""},set:function(a){this.setAttribute("class",a)},configurable:!0},nextElementSibling:{get:function(){if(this.__shady&&void 0!==this.__shady.nextSibling){for(var a=this.nextSibling;a&&a.nodeType!==Node.ELEMENT_NODE;)a=a.nextSibling;return a}return Rb(this)},configurable:!0},previousElementSibling:{get:function(){if(this.__shady&&void 0!==this.__shady.previousSibling){for(var a= -this.previousSibling;a&&a.nodeType!==Node.ELEMENT_NODE;)a=a.previousSibling;return a}return Qb(this)},configurable:!0}},Pa={childNodes:{get:function(){if(T(this)){if(!this.__shady.childNodes){this.__shady.childNodes=[];for(var a=this.firstChild;a;a=a.nextSibling)this.__shady.childNodes.push(a)}var b=this.__shady.childNodes}else b=S(this);b.item=function(a){return b[a]};return b},configurable:!0},childElementCount:{get:function(){return this.children.length},configurable:!0},firstChild:{get:function(){var a= -this.__shady&&this.__shady.firstChild;return void 0!==a?a:Ha(this)},configurable:!0},lastChild:{get:function(){var a=this.__shady&&this.__shady.lastChild;return void 0!==a?a:Ia(this)},configurable:!0},textContent:{get:function(){if(T(this)){for(var a=[],b=0,c=this.childNodes,d;d=c[b];b++)d.nodeType!==Node.COMMENT_NODE&&a.push(d.textContent);return a.join("")}return Ub(this)},set:function(a){switch(this.nodeType){case Node.ELEMENT_NODE:case Node.DOCUMENT_FRAGMENT_NODE:for(;this.firstChild;)this.removeChild(this.firstChild); -this.appendChild(document.createTextNode(a));break;default:this.nodeValue=a}},configurable:!0},firstElementChild:{get:function(){if(this.__shady&&void 0!==this.__shady.firstChild){for(var a=this.firstChild;a&&a.nodeType!==Node.ELEMENT_NODE;)a=a.nextSibling;return a}return Ob(this)},configurable:!0},lastElementChild:{get:function(){if(this.__shady&&void 0!==this.__shady.lastChild){for(var a=this.lastChild;a&&a.nodeType!==Node.ELEMENT_NODE;)a=a.previousSibling;return a}return Pb(this)},configurable:!0}, -children:{get:function(){var a;T(this)?a=Array.prototype.filter.call(this.childNodes,function(a){return a.nodeType===Node.ELEMENT_NODE}):a=Sb(this);a.item=function(b){return a[b]};return a},configurable:!0},innerHTML:{get:function(){var a="template"===this.localName?this.content:this;return T(this)?Oa(a):Tb(a)},set:function(a){for(var b="template"===this.localName?this.content:this;b.firstChild;)b.removeChild(b.firstChild);for(hb&&hb.set?hb.set.call(ta,a):ta.innerHTML=a;ta.firstChild;)b.appendChild(ta.firstChild)}, -configurable:!0}},Oc={shadowRoot:{get:function(){return this.__shady&&this.__shady.tb||null},configurable:!0}},Qa={activeElement:{get:function(){var a=ib&&ib.get?ib.get.call(document):C.V?void 0:document.activeElement;if(a&&a.nodeType){var b=!!L(this);if(this===document||b&&this.host!==a&&this.host.contains(a)){for(b=Z(a);b&&b!==this;)a=b.host,b=Z(a);a=this===document?b?null:a:b===this?a:null}else a=null}else a=null;return a},set:function(){},configurable:!0}},Fb=C.V?function(){}:function(a){a.__shady&& -a.__shady.Xa||(a.__shady=a.__shady||{},a.__shady.Xa=!0,M(a,Vb,!0))},Eb=C.V?function(){}:function(a){a.__shady&&a.__shady.Va||(a.__shady=a.__shady||{},a.__shady.Va=!0,M(a,Pa,!0),M(a,Oc,!0))},pa=null,Sd={blur:!0,focus:!0,focusin:!0,focusout:!0,click:!0,dblclick:!0,mousedown:!0,mouseenter:!0,mouseleave:!0,mousemove:!0,mouseout:!0,mouseover:!0,mouseup:!0,wheel:!0,beforeinput:!0,input:!0,keydown:!0,keyup:!0,compositionstart:!0,compositionupdate:!0,compositionend:!0,touchstart:!0,touchend:!0,touchmove:!0, -touchcancel:!0,pointerover:!0,pointerenter:!0,pointerdown:!0,pointermove:!0,pointerup:!0,pointercancel:!0,pointerout:!0,pointerleave:!0,gotpointercapture:!0,lostpointercapture:!0,dragstart:!0,drag:!0,dragenter:!0,dragleave:!0,dragover:!0,drop:!0,dragend:!0,DOMActivate:!0,DOMFocusIn:!0,DOMFocusOut:!0,keypress:!0},rc={get composed(){!1!==this.isTrusted&&void 0===this.ja&&(this.ja=Sd[this.type]);return this.ja||!1},composedPath:function(){this.ya||(this.ya=Wa(this.__target,this.composed));return this.ya}, -get target(){return hc(this.currentTarget,this.composedPath())},get relatedTarget(){if(!this.za)return null;this.Aa||(this.Aa=Wa(this.za,!0));return hc(this.currentTarget,this.Aa)},stopPropagation:function(){Event.prototype.stopPropagation.call(this);this.ka=!0},stopImmediatePropagation:function(){Event.prototype.stopImmediatePropagation.call(this);this.ka=this.Ua=!0}},Ya={focus:!0,blur:!0},Td=Xa(window.Event),Ud=Xa(window.CustomEvent),Vd=Xa(window.MouseEvent),Db={};l.prototype=Object.create(DocumentFragment.prototype); -l.prototype.D=function(a,b){this.Wa="ShadyRoot";la(a);la(this);this.host=a;this.L=b&&b.mode;a.__shady=a.__shady||{};a.__shady.root=this;a.__shady.tb="closed"!==this.L?this:null;this.S=!1;this.b=[];this.a=null;b=S(a);for(var c=0,d=b.length;cb.__shady.assignedNodes.length&&(b.__shady.qa=!0)}b.__shady.qa&&(b.__shady.qa=!1,this.g(b))}};l.prototype.f=function(a,b){a.__shady=a.__shady||{};var c=a.__shady.na;a.__shady.na=null;b||(b=(b=this.a[a.slot||"__catchall"])&&b[0]);b?(b.__shady.assignedNodes.push(a),a.__shady.assignedSlot=b):a.__shady.assignedSlot=void 0;c!==a.__shady.assignedSlot&& -a.__shady.assignedSlot&&(a.__shady.assignedSlot.__shady.qa=!0)};l.prototype.l=function(a){var b=a.__shady.assignedNodes;a.__shady.assignedNodes=[];a.__shady.U=[];if(a.__shady.Da=b)for(var c=0;cb.indexOf(d))||b.push(d)}for(a=0;a "+b}))}a=a.replace(ge,function(a,b,c){return'[dir="'+c+'"] '+b+", "+b+'[dir="'+c+'"]'});return{value:a,lb:b,stop:f}};r.prototype.s=function(a,b){a=a.split(Qc);a[0]+=b;return a.join(Qc)};r.prototype.L=function(a,b){var c=a.match(Rc);return(c=c&&c[2].trim()||"")?c[0].match(Sc)?a.replace(Rc,function(a,c,f){return b+ -f}):c.split(Sc)[0]===b?c:he:a.replace(jb,b)};r.prototype.I=function(a){a.selector=a.parsedSelector;this.u(a);this.j(a,this.D)};r.prototype.u=function(a){a.selector===ie&&(a.selector="html")};r.prototype.D=function(a){return a.match(kb)?this.g(a,Tc):this.s(a.trim(),Tc)};nb.Object.defineProperties(r.prototype,{c:{configurable:!0,enumerable:!0,get:function(){return"style-scope"}}});var ce=/:(nth[-\w]+)\(([^)]+)\)/,Tc=":not(.style-scope)",Pc=",",ee=/(^|[\s>+~]+)((?:\[.+?\]|[^\s>+~=[])+)/g,Sc=/[[.:#*]/, -jb=":host",ie=":root",kb="::slotted",de=new RegExp("^("+kb+")"),Rc=/(:host)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))/,fe=/(?:::slotted)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))/,ge=/(.*):dir\((?:(ltr|rtl))\)/,be=".",Qc=":",ae="class",he="should_not_match",v=new r;t.get=function(a){return a?a.__styleInfo:null};t.set=function(a,b){return a.__styleInfo=b};t.prototype.c=function(){return this.G};t.prototype._getStyleRules=t.prototype.c;var Uc=function(a){return a.matches||a.matchesSelector||a.mozMatchesSelector||a.msMatchesSelector|| -a.oMatchesSelector||a.webkitMatchesSelector}(window.Element.prototype),je=navigator.userAgent.match("Trident");p.prototype.R=function(a){var b=this,c={},d=[],e=0;W(a,function(a){b.c(a);a.index=e++;b.I(a.w.cssText,c)},function(a){d.push(a)});a.b=d;a=[];for(var f in c)a.push(f);return a};p.prototype.c=function(a){if(!a.w){var b={},c={};this.b(a,c)&&(b.F=c,a.rules=null);b.cssText=this.H(a);a.w=b}};p.prototype.b=function(a,b){var c=a.w;if(c){if(c.F)return Object.assign(b,c.F),!0}else{c=a.parsedCssText; -for(var d;a=va.exec(c);){d=(a[2]||a[3]).trim();if("inherit"!==d||"unset"!==d)b[a[1].trim()]=d;d=!0}return d}};p.prototype.H=function(a){return this.L(a.parsedCssText)};p.prototype.L=function(a){return a.replace($d,"").replace(va,"")};p.prototype.I=function(a,b){for(var c;c=Yd.exec(a);){var d=c[1];":"!==c[2]&&(b[d]=!0)}};p.prototype.fa=function(a){for(var b=Object.getOwnPropertyNames(a),c=0,d;c *"===f||"html"===f,h=0===f.indexOf(":host")&&!g;"shady"===c&&(g=f===e+" > *."+e||-1!==f.indexOf("html"),h=!g&&0===f.indexOf(e));"shadow"===c&&(g=":host > *"===f||"html"===f,h=h&&!g);if(g||h)c=e,h&&(w&&!b.A&&(b.A=v.l(b,v.g,v.h(a),e)),c=b.A||e),d({xb:c,qb:h,Gb:g})}};p.prototype.da=function(a,b){var c={},d= -{},e=this,f=b&&b.__cssBuild;W(b,function(b){e.ha(a,b,f,function(f){Uc.call(a.Db||a,f.xb)&&(f.qb?e.b(b,c):e.b(b,d))})},null,!0);return{vb:d,pb:c}};p.prototype.ga=function(a,b,c){var d=this,e=Q(a),f=v.f(e.is,e.Y),g=new RegExp("(?:^|[^.#[:])"+(a.extends?"\\"+f.slice(0,-1)+"\\]":f)+"($|[.:[\\s>+~])");e=t.get(a).G;var h=this.h(e,c);return v.b(a,e,function(a){d.D(a,b);w||Bc(a)||!a.cssText||(d.C(a,h),d.l(a,g,f,c))})};p.prototype.h=function(a,b){a=a.b;var c={};if(!w&&a)for(var d=0,e=a[d];d=f._useCount&&f.parentNode&&f.parentNode.removeChild(f));w?e.a?(e.a.textContent=b,d=e.a):b&&(d=bb(b,c,a.shadowRoot,e.b)):d?d.parentNode||(je&&-1this.c&&e.shift();this.cache[a]=e};ha.prototype.fetch=function(a,b,c){if(a=this.cache[a])for(var d=a.length-1;0<=d;d--){var e=a[d];if(this.a(e,b,c))return e}};if(!w){var Vc=new MutationObserver(Ec),Wc=function(a){Vc.observe(a, -{childList:!0,subtree:!0})};if(window.customElements&&!window.customElements.polyfillWrapFlushCallback)Wc(document);else{var mb=function(){Wc(document.body)};window.HTMLImports?window.HTMLImports.whenReady(mb):requestAnimationFrame(function(){if("loading"===document.readyState){var a=function(){mb();document.removeEventListener("readystatechange",a)};document.addEventListener("readystatechange",a)}else mb()})}pb=function(){Ec(Vc.takeRecords())}}var sa={},Ld=Promise.resolve(),cb=null,Gc=window.HTMLImports&& -window.HTMLImports.whenReady||null,db,ya=null,fa=null;H.prototype.Ga=function(){!this.enqueued&&fa&&(this.enqueued=!0,ob(fa))};H.prototype.b=function(a){a.__seenByShadyCSS||(a.__seenByShadyCSS=!0,this.customStyles.push(a),this.Ga())};H.prototype.a=function(a){return a.__shadyCSSCachedStyle?a.__shadyCSSCachedStyle:a.getStyle?a.getStyle():a};H.prototype.c=function(){for(var a=this.customStyles,b=0;b= dt.as_local(dev_flow.user_code_expiry): hass.components.persistent_notification.create( - 'Authenication code expired, please restart ' + 'Authentication code expired, please restart ' 'Home-Assistant and try again', title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) @@ -190,8 +192,7 @@ def _found_calendar(call): hass.data[DATA_INDEX][calendar[CONF_CAL_ID]]) hass.services.register( - DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar, - None, schema=None) + DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar) def _scan_for_calendars(service): """Scan for new calendars.""" @@ -204,9 +205,7 @@ def _scan_for_calendars(service): calendar) hass.services.register( - DOMAIN, SERVICE_SCAN_CALENDARS, - _scan_for_calendars, - None, schema=None) + DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars) return True @@ -269,7 +268,7 @@ def load_config(path): calendars = {} try: with open(path) as file: - data = yaml.load(file) + data = yaml.safe_load(file) for calendar in data: try: calendars.update({calendar[CONF_CAL_ID]: diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py new file mode 100644 index 0000000000000..1c6d11a7c9921 --- /dev/null +++ b/homeassistant/components/google_assistant/__init__.py @@ -0,0 +1,104 @@ +""" +Support for Actions on Google Assistant 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 + +import aiohttp +import async_timeout + +import voluptuous as vol + +# Typing imports +# pylint: disable=using-constant-test,unused-import,ungrouped-imports +from homeassistant.core import HomeAssistant # NOQA +from typing import Dict, Any # NOQA + +from homeassistant.const import CONF_NAME +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.loader import bind_hass + +from .const import ( + DOMAIN, CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN, + CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, + DEFAULT_EXPOSED_DOMAINS, CONF_AGENT_USER_ID, CONF_API_KEY, + SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL, CONF_ENTITY_CONFIG, + CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT +) +from .auth import GoogleAssistantAuthView +from .http import async_register_http + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + +DEFAULT_AGENT_USER_ID = 'home-assistant' + +ENTITY_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_EXPOSE): cv.boolean, + vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ROOM_HINT): cv.string +}) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: { + vol.Required(CONF_PROJECT_ID): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_EXPOSE_BY_DEFAULT, + default=DEFAULT_EXPOSE_BY_DEFAULT): cv.boolean, + vol.Optional(CONF_EXPOSED_DOMAINS, + default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list, + vol.Optional(CONF_AGENT_USER_ID, + default=DEFAULT_AGENT_USER_ID): cv.string, + vol.Optional(CONF_API_KEY): cv.string, + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA} + } + }, + extra=vol.ALLOW_EXTRA) + + +@bind_hass +def request_sync(hass): + """Request sync.""" + hass.services.call(DOMAIN, SERVICE_REQUEST_SYNC) + + +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) + api_key = config.get(CONF_API_KEY) + hass.http.register_view(GoogleAssistantAuthView(hass, config)) + async_register_http(hass, config) + + 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 = 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 = await res.read() + _LOGGER.error( + 'request_sync request failed: %d %s', res.status, body) + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Could not contact Google for request_sync") + + # Register service only if api key is provided + if api_key is not None: + hass.services.async_register( + DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler) + + return True diff --git a/homeassistant/components/google_assistant/auth.py b/homeassistant/components/google_assistant/auth.py new file mode 100644 index 0000000000000..a21dd0e673859 --- /dev/null +++ b/homeassistant/components/google_assistant/auth.py @@ -0,0 +1,84 @@ +"""Google Assistant OAuth View.""" + +import logging + +# Typing imports +# pylint: disable=using-constant-test,unused-import,ungrouped-imports +# if False: +from aiohttp.web import Request, Response # NOQA +from typing import Dict, Any # NOQA + +from homeassistant.core import HomeAssistant # NOQA +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + HTTP_BAD_REQUEST, + HTTP_UNAUTHORIZED, + HTTP_MOVED_PERMANENTLY, +) + +from .const import ( + GOOGLE_ASSISTANT_API_ENDPOINT, + CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN +) + +BASE_OAUTH_URL = 'https://oauth-redirect.googleusercontent.com' +REDIRECT_TEMPLATE_URL = \ + '{}/r/{}#access_token={}&token_type=bearer&state={}' + +_LOGGER = logging.getLogger(__name__) + + +class GoogleAssistantAuthView(HomeAssistantView): + """Handle Google Actions auth requests.""" + + url = GOOGLE_ASSISTANT_API_ENDPOINT + '/auth' + name = 'api:google_assistant:auth' + requires_auth = False + + def __init__(self, hass: HomeAssistant, cfg: Dict[str, Any]) -> None: + """Initialize instance of the view.""" + super().__init__() + + self.project_id = cfg.get(CONF_PROJECT_ID) + self.client_id = cfg.get(CONF_CLIENT_ID) + self.access_token = cfg.get(CONF_ACCESS_TOKEN) + + async def get(self, request: Request) -> Response: + """Handle oauth token request.""" + query = request.query + redirect_uri = query.get('redirect_uri') + if not redirect_uri: + msg = 'missing redirect_uri field' + _LOGGER.warning(msg) + return self.json_message(msg, status_code=HTTP_BAD_REQUEST) + + if self.project_id not in redirect_uri: + msg = 'missing project_id in redirect_uri' + _LOGGER.warning(msg) + return self.json_message(msg, status_code=HTTP_BAD_REQUEST) + + state = query.get('state') + if not state: + msg = 'oauth request missing state' + _LOGGER.warning(msg) + return self.json_message(msg, status_code=HTTP_BAD_REQUEST) + + client_id = query.get('client_id') + if self.client_id != client_id: + msg = 'invalid client id' + _LOGGER.warning(msg) + return self.json_message(msg, status_code=HTTP_UNAUTHORIZED) + + generated_url = redirect_url(self.project_id, self.access_token, state) + + _LOGGER.info('user login in from Google Assistant') + return self.json_message( + 'redirect success', + status_code=HTTP_MOVED_PERMANENTLY, + headers={'Location': generated_url}) + + +def redirect_url(project_id: str, access_token: str, state: str) -> str: + """Generate the redirect format for the oauth request.""" + return REDIRECT_TEMPLATE_URL.format(BASE_OAUTH_URL, project_id, + access_token, state) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py new file mode 100644 index 0000000000000..12888ea2cf690 --- /dev/null +++ b/homeassistant/components/google_assistant/const.py @@ -0,0 +1,42 @@ +"""Constants for Google Assistant.""" +DOMAIN = 'google_assistant' + +GOOGLE_ASSISTANT_API_ENDPOINT = '/api/google_assistant' + +CONF_EXPOSE = 'expose' +CONF_ENTITY_CONFIG = 'entity_config' +CONF_EXPOSE_BY_DEFAULT = 'expose_by_default' +CONF_EXPOSED_DOMAINS = 'exposed_domains' +CONF_PROJECT_ID = 'project_id' +CONF_ACCESS_TOKEN = 'access_token' +CONF_CLIENT_ID = 'client_id' +CONF_ALIASES = 'aliases' +CONF_AGENT_USER_ID = 'agent_user_id' +CONF_API_KEY = 'api_key' +CONF_ROOM_HINT = 'room' + +DEFAULT_EXPOSE_BY_DEFAULT = True +DEFAULT_EXPOSED_DOMAINS = [ + 'switch', 'light', 'group', 'media_player', 'fan', 'cover', 'climate' +] +CLIMATE_MODE_HEATCOOL = 'heatcool' +CLIMATE_SUPPORTED_MODES = {'heat', 'cool', 'off', 'on', CLIMATE_MODE_HEATCOOL} + +PREFIX_TYPES = 'action.devices.types.' +TYPE_LIGHT = PREFIX_TYPES + 'LIGHT' +TYPE_SWITCH = PREFIX_TYPES + 'SWITCH' +TYPE_SCENE = PREFIX_TYPES + 'SCENE' +TYPE_THERMOSTAT = PREFIX_TYPES + 'THERMOSTAT' + +SERVICE_REQUEST_SYNC = 'request_sync' +HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' +REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync' + +# Error codes used for SmartHomeError class +# https://developers.google.com/actions/smarthome/create-app#error_responses +ERR_DEVICE_OFFLINE = "deviceOffline" +ERR_DEVICE_NOT_FOUND = "deviceNotFound" +ERR_VALUE_OUT_OF_RANGE = "valueOutOfRange" +ERR_NOT_SUPPORTED = "notSupported" +ERR_PROTOCOL_ERROR = 'protocolError' +ERR_UNKNOWN_ERROR = 'unknownError' diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py new file mode 100644 index 0000000000000..ef6ae109eb564 --- /dev/null +++ b/homeassistant/components/google_assistant/helpers.py @@ -0,0 +1,23 @@ +"""Helper classes for Google Assistant integration.""" + + +class SmartHomeError(Exception): + """Google Assistant Smart Home errors. + + https://developers.google.com/actions/smarthome/create-app#error_responses + """ + + def __init__(self, code, msg): + """Log error code.""" + super().__init__(msg) + self.code = code + + +class Config: + """Hold the configuration for Google Assistant.""" + + def __init__(self, should_expose, agent_user_id, entity_config=None): + """Initialize the configuration.""" + self.should_expose = should_expose + self.agent_user_id = agent_user_id + self.entity_config = entity_config or {} diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py new file mode 100644 index 0000000000000..0ea5f7d9fa437 --- /dev/null +++ b/homeassistant/components/google_assistant/http.py @@ -0,0 +1,88 @@ +""" +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 logging + +from aiohttp.hdrs import AUTHORIZATION +from aiohttp.web import Request, Response # NOQA + +# Typing imports +# pylint: disable=using-constant-test,unused-import,ungrouped-imports +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import HomeAssistant, callback # NOQA +from homeassistant.helpers.entity import Entity # NOQA + +from .const import ( + GOOGLE_ASSISTANT_API_ENDPOINT, + CONF_ACCESS_TOKEN, + CONF_EXPOSE_BY_DEFAULT, + CONF_EXPOSED_DOMAINS, + CONF_AGENT_USER_ID, + CONF_ENTITY_CONFIG, + CONF_EXPOSE, + ) +from .smart_home import async_handle_message +from .helpers import Config + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_register_http(hass, cfg): + """Register HTTP views for Google Assistant.""" + access_token = cfg.get(CONF_ACCESS_TOKEN) + expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT) + exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS) + agent_user_id = cfg.get(CONF_AGENT_USER_ID) + entity_config = cfg.get(CONF_ENTITY_CONFIG) or {} + + def is_exposed(entity) -> bool: + """Determine if an entity should be exposed to Google Assistant.""" + if entity.attributes.get('view') is not None: + # Ignore entities that are views + return False + + explicit_expose = \ + entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE) + + domain_exposed_by_default = \ + expose_by_default and entity.domain in exposed_domains + + # Expose an entity if the entity's domain is exposed by default and + # the configuration doesn't explicitly exclude it from being + # exposed, or if the entity is explicitly exposed + is_default_exposed = \ + domain_exposed_by_default and explicit_expose is not False + + return is_default_exposed or explicit_expose + + gass_config = Config(is_exposed, agent_user_id, entity_config) + hass.http.register_view( + GoogleAssistantView(access_token, gass_config)) + + +class GoogleAssistantView(HomeAssistantView): + """Handle Google Assistant requests.""" + + url = GOOGLE_ASSISTANT_API_ENDPOINT + name = 'api:google_assistant' + requires_auth = False # Uses access token from oauth flow + + def __init__(self, access_token, gass_config): + """Initialize the Google Assistant request handler.""" + self.access_token = access_token + self.gass_config = gass_config + + 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 = 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/services.yaml b/homeassistant/components/google_assistant/services.yaml new file mode 100644 index 0000000000000..6019b75bd9830 --- /dev/null +++ b/homeassistant/components/google_assistant/services.yaml @@ -0,0 +1,2 @@ +request_sync: + description: Send a request_sync command to Google. \ No newline at end of file diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py new file mode 100644 index 0000000000000..27d993aee76ab --- /dev/null +++ b/homeassistant/components/google_assistant/smart_home.py @@ -0,0 +1,334 @@ +"""Support for Google Assistant Smart Home API.""" +import collections +from itertools import product +import logging + +# Typing imports +# pylint: disable=using-constant-test,unused-import,ungrouped-imports +# if False: +from aiohttp.web import Request, Response # NOQA +from typing import Dict, Tuple, Any, Optional # NOQA +from homeassistant.helpers.entity import Entity # NOQA +from homeassistant.core import HomeAssistant # NOQA +from homeassistant.util.unit_system import UnitSystem # NOQA +from homeassistant.util.decorator import Registry + +from homeassistant.core import callback +from homeassistant.const import ( + CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES) +from homeassistant.components import ( + climate, + cover, + fan, + group, + input_boolean, + light, + media_player, + scene, + script, + switch, +) + +from . import trait +from .const import ( + TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH, TYPE_THERMOSTAT, + CONF_ALIASES, CONF_ROOM_HINT, + ERR_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, + ERR_UNKNOWN_ERROR +) +from .helpers import SmartHomeError + +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, +} + + +def deep_update(target, source): + """Update a nested dictionary with another nested dictionary.""" + for key, value in source.items(): + if isinstance(value, collections.Mapping): + target[key] = deep_update(target.get(key, {}), value) + else: + target[key] = value + return target + + +class _GoogleEntity: + """Adaptation of Entity expressed in Google's terms.""" + + def __init__(self, hass, config, state): + self.hass = hass + self.config = config + self.state = state + + @property + def entity_id(self): + """Return entity ID.""" + return self.state.entity_id + + @callback + def traits(self): + """Return traits for entity.""" + state = self.state + domain = state.domain + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + return [Trait(state) for Trait in trait.TRAITS + if Trait.supported(domain, features)] + + @callback + def sync_serialize(self): + """Serialize entity for a SYNC response. + + https://developers.google.com/actions/smarthome/create-app#actiondevicessync + """ + 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 + + device = { + 'id': state.entity_id, + 'name': { + 'name': name + }, + 'attributes': {}, + 'traits': [trait.name for trait in traits], + 'willReportState': False, + 'type': DOMAIN_TO_GOOGLE_TYPES[state.domain], + } + + # use aliases + aliases = entity_config.get(CONF_ALIASES) + if aliases: + device['name']['nicknames'] = aliases + + # add room hint if annotated + room = entity_config.get(CONF_ROOM_HINT) + if room: + device['roomHint'] = room + + for trt in traits: + device['attributes'].update(trt.sync_attributes()) + + return device + + @callback + def query_serialize(self): + """Serialize entity for a QUERY response. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesquery + """ + state = self.state + + if state.state == STATE_UNAVAILABLE: + return {'online': False} + + attrs = {'online': True} + + for trt in self.traits(): + deep_update(attrs, trt.query_attributes()) + + return attrs + + async def execute(self, command, params): + """Execute a command. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute + """ + executed = False + for trt in self.traits(): + if trt.can_execute(command, params): + await trt.execute(self.hass, command, params) + executed = True + break + + if not executed: + raise SmartHomeError( + ERR_NOT_SUPPORTED, + 'Unable to execute {} for {}'.format(command, + self.state.entity_id)) + + @callback + def async_update(self): + """Update the entity with latest info from Home Assistant.""" + self.state = self.hass.states.get(self.entity_id) + + +async def async_handle_message(hass, config, message): + """Handle incoming API messages.""" + response = await _process(hass, config, message) + + if 'errorCode' in response['payload']: + _LOGGER.error('Error handling message %s: %s', + message, response['payload']) + + return response + + +async def _process(hass, config, message): + """Process a message.""" + request_id = message.get('requestId') # type: str + inputs = message.get('inputs') # type: list + + if len(inputs) != 1: + return { + 'requestId': request_id, + 'payload': {'errorCode': ERR_PROTOCOL_ERROR} + } + + handler = HANDLERS.get(inputs[0].get('intent')) + + if handler is None: + return { + 'requestId': request_id, + 'payload': {'errorCode': ERR_PROTOCOL_ERROR} + } + + try: + result = await handler(hass, config, inputs[0].get('payload')) + return {'requestId': request_id, 'payload': result} + except SmartHomeError as err: + return { + 'requestId': request_id, + 'payload': {'errorCode': err.code} + } + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception('Unexpected error') + return { + 'requestId': request_id, + 'payload': {'errorCode': ERR_UNKNOWN_ERROR} + } + + +@HANDLERS.register('action.devices.SYNC') +async def async_devices_sync(hass, config, payload): + """Handle action.devices.SYNC request. + + https://developers.google.com/actions/smarthome/create-app#actiondevicessync + """ + devices = [] + for state in hass.states.async_all(): + if not config.should_expose(state): + continue + + entity = _GoogleEntity(hass, config, state) + serialized = entity.sync_serialize() + + if serialized is None: + _LOGGER.debug("No mapping for %s domain", entity.state) + continue + + devices.append(serialized) + + return { + 'agentUserId': config.agent_user_id, + 'devices': devices, + } + + +@HANDLERS.register('action.devices.QUERY') +async def async_devices_query(hass, config, payload): + """Handle action.devices.QUERY request. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesquery + """ + devices = {} + for device in payload.get('devices', []): + devid = device['id'] + state = hass.states.get(devid) + + if not state: + # If we can't find a state, the device is offline + devices[devid] = {'online': False} + continue + + devices[devid] = _GoogleEntity(hass, config, state).query_serialize() + + return {'devices': devices} + + +@HANDLERS.register('action.devices.EXECUTE') +async def handle_devices_execute(hass, config, payload): + """Handle action.devices.EXECUTE request. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute + """ + entities = {} + results = {} + + for command in payload['commands']: + for device, execution in product(command['devices'], + command['execution']): + entity_id = device['id'] + + # Happens if error occurred. Skip entity for further processing + if entity_id in results: + continue + + if entity_id not in entities: + state = hass.states.get(entity_id) + + if state is None: + results[entity_id] = { + 'ids': [entity_id], + 'status': 'ERROR', + 'errorCode': ERR_DEVICE_OFFLINE + } + continue + + entities[entity_id] = _GoogleEntity(hass, config, state) + + try: + await entities[entity_id].execute(execution['command'], + execution.get('params', {})) + except SmartHomeError as err: + results[entity_id] = { + 'ids': [entity_id], + 'status': 'ERROR', + 'errorCode': err.code + } + + final_results = list(results.values()) + + for entity in entities.values(): + if entity.entity_id in results: + continue + + entity.async_update() + + final_results.append({ + 'ids': [entity.entity_id], + 'status': 'SUCCESS', + 'states': entity.query_serialize(), + }) + + return {'commands': final_results} diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py new file mode 100644 index 0000000000000..2f60f226042bb --- /dev/null +++ b/homeassistant/components/google_assistant/trait.py @@ -0,0 +1,522 @@ +"""Implement the Smart Home traits.""" +from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.components import ( + climate, + cover, + group, + fan, + input_boolean, + media_player, + light, + scene, + script, + switch, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.util import color as color_util, temperature as temp_util + +from .const import ERR_VALUE_OUT_OF_RANGE +from .helpers import SmartHomeError + +PREFIX_TRAITS = 'action.devices.traits.' +TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff' +TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness' +TRAIT_COLOR_SPECTRUM = PREFIX_TRAITS + 'ColorSpectrum' +TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature' +TRAIT_SCENE = PREFIX_TRAITS + 'Scene' +TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' + +PREFIX_COMMANDS = 'action.devices.commands.' +COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' +COMMAND_BRIGHTNESS_ABSOLUTE = PREFIX_COMMANDS + 'BrightnessAbsolute' +COMMAND_COLOR_ABSOLUTE = PREFIX_COMMANDS + 'ColorAbsolute' +COMMAND_ACTIVATE_SCENE = PREFIX_COMMANDS + 'ActivateScene' +COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( + PREFIX_COMMANDS + 'ThermostatTemperatureSetpoint') +COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( + PREFIX_COMMANDS + 'ThermostatTemperatureSetRange') +COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' + + +TRAITS = [] + + +def register_trait(trait): + """Decorator to register a trait.""" + TRAITS.append(trait) + return trait + + +def _google_temp_unit(state): + """Return Google temperature unit.""" + if (state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == + TEMP_FAHRENHEIT): + return 'F' + return 'C' + + +class _Trait: + """Represents a Trait inside Google Assistant skill.""" + + commands = [] + + def __init__(self, state): + """Initialize a trait for a state.""" + self.state = state + + def sync_attributes(self): + """Return attributes for a sync request.""" + raise NotImplementedError + + def query_attributes(self): + """Return the attributes of this trait for this entity.""" + raise NotImplementedError + + def can_execute(self, command, params): + """Test if command can be executed.""" + return command in self.commands + + async def execute(self, hass, command, params): + """Execute a trait command.""" + raise NotImplementedError + + +@register_trait +class BrightnessTrait(_Trait): + """Trait to control brightness of a device. + + https://developers.google.com/actions/smarthome/traits/brightness + """ + + name = TRAIT_BRIGHTNESS + commands = [ + COMMAND_BRIGHTNESS_ABSOLUTE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain == light.DOMAIN: + return features & light.SUPPORT_BRIGHTNESS + elif domain == cover.DOMAIN: + return features & cover.SUPPORT_SET_POSITION + elif domain == media_player.DOMAIN: + return features & media_player.SUPPORT_VOLUME_SET + + return False + + def sync_attributes(self): + """Return brightness attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return brightness query attributes.""" + domain = self.state.domain + response = {} + + if domain == light.DOMAIN: + brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS) + if brightness is not None: + response['brightness'] = int(100 * (brightness / 255)) + + elif domain == cover.DOMAIN: + position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) + if position is not None: + response['brightness'] = position + + elif domain == media_player.DOMAIN: + level = self.state.attributes.get( + media_player.ATTR_MEDIA_VOLUME_LEVEL) + if level is not None: + # Convert 0.0-1.0 to 0-255 + response['brightness'] = int(level * 100) + + return response + + async def execute(self, hass, command, params): + """Execute a brightness command.""" + domain = self.state.domain + + if domain == light.DOMAIN: + await hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_BRIGHTNESS_PCT: params['brightness'] + }, blocking=True) + elif domain == cover.DOMAIN: + await hass.services.async_call( + cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION, { + ATTR_ENTITY_ID: self.state.entity_id, + cover.ATTR_POSITION: params['brightness'] + }, blocking=True) + elif domain == media_player.DOMAIN: + await hass.services.async_call( + media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: + params['brightness'] / 100 + }, blocking=True) + + +@register_trait +class OnOffTrait(_Trait): + """Trait to offer basic on and off functionality. + + https://developers.google.com/actions/smarthome/traits/onoff + """ + + name = TRAIT_ONOFF + commands = [ + COMMAND_ONOFF + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + return domain in ( + group.DOMAIN, + input_boolean.DOMAIN, + switch.DOMAIN, + fan.DOMAIN, + light.DOMAIN, + cover.DOMAIN, + media_player.DOMAIN, + ) + + def sync_attributes(self): + """Return OnOff attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return OnOff query attributes.""" + if self.state.domain == cover.DOMAIN: + return {'on': self.state.state != cover.STATE_CLOSED} + return {'on': self.state.state != STATE_OFF} + + async def execute(self, hass, command, params): + """Execute an OnOff command.""" + domain = self.state.domain + + if domain == cover.DOMAIN: + service_domain = domain + if params['on']: + service = cover.SERVICE_OPEN_COVER + else: + service = cover.SERVICE_CLOSE_COVER + + elif domain == group.DOMAIN: + service_domain = HA_DOMAIN + service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF + + else: + service_domain = domain + service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF + + await hass.services.async_call(service_domain, service, { + ATTR_ENTITY_ID: self.state.entity_id + }, blocking=True) + + +@register_trait +class ColorSpectrumTrait(_Trait): + """Trait to offer color spectrum functionality. + + https://developers.google.com/actions/smarthome/traits/colorspectrum + """ + + name = TRAIT_COLOR_SPECTRUM + commands = [ + COMMAND_COLOR_ABSOLUTE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != light.DOMAIN: + return False + + return features & light.SUPPORT_COLOR + + def sync_attributes(self): + """Return color spectrum attributes for a sync request.""" + # Other colorModel is hsv + return {'colorModel': 'rgb'} + + def query_attributes(self): + """Return color spectrum query attributes.""" + response = {} + + 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_util.color_hs_to_RGB(*color_hs)), 16), + } + + return response + + def can_execute(self, command, params): + """Test if command can be executed.""" + return (command in self.commands and + 'spectrumRGB' in params.get('color', {})) + + async def execute(self, hass, command, params): + """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.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_HS_COLOR: color + }, blocking=True) + + +@register_trait +class ColorTemperatureTrait(_Trait): + """Trait to offer color temperature functionality. + + https://developers.google.com/actions/smarthome/traits/colortemperature + """ + + name = TRAIT_COLOR_TEMP + commands = [ + COMMAND_COLOR_ABSOLUTE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != light.DOMAIN: + return False + + return features & light.SUPPORT_COLOR_TEMP + + def sync_attributes(self): + """Return color temperature attributes for a sync request.""" + attrs = self.state.attributes + return { + 'temperatureMinK': color_util.color_temperature_mired_to_kelvin( + attrs.get(light.ATTR_MIN_MIREDS)), + 'temperatureMaxK': color_util.color_temperature_mired_to_kelvin( + attrs.get(light.ATTR_MAX_MIREDS)), + } + + def query_attributes(self): + """Return color temperature query attributes.""" + response = {} + + temp = self.state.attributes.get(light.ATTR_COLOR_TEMP) + if temp is not None: + response['color'] = { + 'temperature': + color_util.color_temperature_mired_to_kelvin(temp) + } + + return response + + def can_execute(self, command, params): + """Test if command can be executed.""" + return (command in self.commands and + 'temperature' in params.get('color', {})) + + async def execute(self, hass, command, params): + """Execute a color temperature command.""" + temp = color_util.color_temperature_kelvin_to_mired( + params['color']['temperature']) + min_temp = self.state.attributes[light.ATTR_MIN_MIREDS] + max_temp = self.state.attributes[light.ATTR_MAX_MIREDS] + + if temp < min_temp or temp > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Temperature should be between {} and {}".format(min_temp, + max_temp)) + + await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_COLOR_TEMP: temp, + }, blocking=True) + + +@register_trait +class SceneTrait(_Trait): + """Trait to offer scene functionality. + + https://developers.google.com/actions/smarthome/traits/scene + """ + + name = TRAIT_SCENE + commands = [ + COMMAND_ACTIVATE_SCENE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + return domain in (scene.DOMAIN, script.DOMAIN) + + def sync_attributes(self): + """Return scene attributes for a sync request.""" + # Neither supported domain can support sceneReversible + return {} + + def query_attributes(self): + """Return scene query attributes.""" + return {} + + async def execute(self, hass, command, params): + """Execute a scene command.""" + # Don't block for scripts as they can be slow. + await hass.services.async_call(self.state.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id + }, blocking=self.state.domain != script.DOMAIN) + + +@register_trait +class TemperatureSettingTrait(_Trait): + """Trait to offer handling both temperature point and modes functionality. + + https://developers.google.com/actions/smarthome/traits/temperaturesetting + """ + + name = TRAIT_TEMPERATURE_SETTING + commands = [ + COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, + COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, + COMMAND_THERMOSTAT_SET_MODE, + ] + # We do not support "on" as we are unable to know how to restore + # the last mode. + hass_to_google = { + climate.STATE_HEAT: 'heat', + climate.STATE_COOL: 'cool', + climate.STATE_OFF: 'off', + climate.STATE_AUTO: 'heatcool', + } + google_to_hass = {value: key for key, value in hass_to_google.items()} + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != climate.DOMAIN: + return False + + return features & climate.SUPPORT_OPERATION_MODE + + def sync_attributes(self): + """Return temperature point and modes attributes for a sync request.""" + modes = [] + for mode in self.state.attributes.get(climate.ATTR_OPERATION_LIST, []): + google_mode = self.hass_to_google.get(mode) + if google_mode is not None: + modes.append(google_mode) + + return { + 'availableThermostatModes': ','.join(modes), + 'thermostatTemperatureUnit': _google_temp_unit(self.state), + } + + def query_attributes(self): + """Return temperature point and modes query attributes.""" + attrs = self.state.attributes + response = {} + + operation = attrs.get(climate.ATTR_OPERATION_MODE) + if operation is not None and operation in self.hass_to_google: + response['thermostatMode'] = self.hass_to_google[operation] + + unit = self.state.attributes[ATTR_UNIT_OF_MEASUREMENT] + + current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE) + if current_temp is not None: + response['thermostatTemperatureAmbient'] = \ + round(temp_util.convert(current_temp, unit, TEMP_CELSIUS), 1) + + current_humidity = attrs.get(climate.ATTR_CURRENT_HUMIDITY) + if current_humidity is not None: + response['thermostatHumidityAmbient'] = current_humidity + + if (operation == climate.STATE_AUTO and + climate.ATTR_TARGET_TEMP_HIGH in attrs and + climate.ATTR_TARGET_TEMP_LOW in attrs): + response['thermostatTemperatureSetpointHigh'] = \ + round(temp_util.convert(attrs[climate.ATTR_TARGET_TEMP_HIGH], + unit, TEMP_CELSIUS), 1) + response['thermostatTemperatureSetpointLow'] = \ + round(temp_util.convert(attrs[climate.ATTR_TARGET_TEMP_LOW], + unit, TEMP_CELSIUS), 1) + else: + target_temp = attrs.get(climate.ATTR_TEMPERATURE) + if target_temp is not None: + response['thermostatTemperatureSetpoint'] = round( + temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1) + + return response + + async def execute(self, hass, command, params): + """Execute a temperature point or mode command.""" + # All sent in temperatures are always in Celsius + unit = self.state.attributes[ATTR_UNIT_OF_MEASUREMENT] + min_temp = self.state.attributes[climate.ATTR_MIN_TEMP] + max_temp = self.state.attributes[climate.ATTR_MAX_TEMP] + + if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: + temp = temp_util.convert(params['thermostatTemperatureSetpoint'], + TEMP_CELSIUS, unit) + + if temp < min_temp or temp > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Temperature should be between {} and {}".format(min_temp, + max_temp)) + + await hass.services.async_call( + climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_TEMPERATURE: temp + }, blocking=True) + + elif command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: + temp_high = temp_util.convert( + params['thermostatTemperatureSetpointHigh'], TEMP_CELSIUS, + unit) + + if temp_high < min_temp or temp_high > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Upper bound for temperature range should be between " + "{} and {}".format(min_temp, max_temp)) + + temp_low = temp_util.convert( + params['thermostatTemperatureSetpointLow'], TEMP_CELSIUS, unit) + + if temp_low < min_temp or temp_low > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Lower bound for temperature range should be between " + "{} and {}".format(min_temp, max_temp)) + + await hass.services.async_call( + climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_TARGET_TEMP_HIGH: temp_high, + climate.ATTR_TARGET_TEMP_LOW: temp_low, + }, blocking=True) + + elif command == COMMAND_THERMOSTAT_SET_MODE: + await hass.services.async_call( + climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE, { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_OPERATION_MODE: + self.google_to_hass[params['thermostatMode']], + }, blocking=True) diff --git a/homeassistant/components/google_domains.py b/homeassistant/components/google_domains.py new file mode 100644 index 0000000000000..3b414306be557 --- /dev/null +++ b/homeassistant/components/google_domains.py @@ -0,0 +1,94 @@ +""" +Integrate with Google Domains. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/google_domains/ +""" +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.const import ( + CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME) + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'google_domains' + +INTERVAL = timedelta(minutes=5) + +DEFAULT_TIMEOUT = 10 + +UPDATE_URL = 'https://{}:{}@domains.google.com/nic/update' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the Google Domains component.""" + domain = config[DOMAIN].get(CONF_DOMAIN) + user = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + timeout = config[DOMAIN].get(CONF_TIMEOUT) + + session = hass.helpers.aiohttp_client.async_get_clientsession() + + result = yield from _update_google_domains( + hass, session, domain, user, password, timeout) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the Google Domains entry.""" + yield from _update_google_domains( + hass, session, domain, user, password, timeout) + + hass.helpers.event.async_track_time_interval( + update_domain_interval, INTERVAL) + + return True + + +@asyncio.coroutine +def _update_google_domains(hass, session, domain, user, password, timeout): + """Update Google Domains.""" + url = UPDATE_URL.format(user, password) + + params = { + 'hostname': domain + } + + try: + with async_timeout.timeout(timeout, loop=hass.loop): + resp = yield from session.get(url, params=params) + body = yield from resp.text() + + if body.startswith('good') or body.startswith('nochg'): + return True + + _LOGGER.warning('Updating Google Domains failed: %s => %s', + domain, body) + + except aiohttp.ClientError: + _LOGGER.warning("Can't connect to Google Domains API") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from Google Domains API for domain: %s", + domain) + + return False diff --git a/homeassistant/components/group.py b/homeassistant/components/group/__init__.py similarity index 81% rename from homeassistant/components/group.py rename to homeassistant/components/group/__init__.py index fb910109d7c41..a33e91f3aa959 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group/__init__.py @@ -6,23 +6,22 @@ """ import asyncio import logging -import os import voluptuous as vol -from homeassistant import config as conf_util, core as ha +from homeassistant import core as ha 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 from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change import homeassistant.helpers.config_validation as cv -from homeassistant.util.async import run_coroutine_threadsafe +from homeassistant.util.async_ import run_coroutine_threadsafe DOMAIN = 'group' @@ -36,8 +35,6 @@ 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' @@ -146,7 +143,7 @@ def set_visibility(hass, entity_id=None, visible=True): @bind_hass def set_group(hass, object_id, name=None, entity_ids=None, visible=None, icon=None, view=None, control=None, add=None): - """Create a new user group.""" + """Create/Update a group.""" hass.add_job( async_set_group, hass, object_id, name, entity_ids, visible, icon, view, control, add) @@ -156,7 +153,7 @@ def set_group(hass, object_id, name=None, entity_ids=None, visible=None, @bind_hass def async_set_group(hass, object_id, name=None, entity_ids=None, visible=None, icon=None, view=None, control=None, add=None): - """Create a new user group.""" + """Create/Update a group.""" data = { key: value for key, value in [ (ATTR_OBJECT_ID, object_id), @@ -246,38 +243,38 @@ def get_entity_ids(hass, entity_id, domain_filter=None): if ent_id.startswith(domain_filter)] -@asyncio.coroutine -def async_setup(hass, config): - """Set up all groups found definded in the configuration.""" - component = EntityComponent(_LOGGER, DOMAIN, hass) - service_groups = {} +async def async_setup(hass, config): + """Set up all groups found defined in the configuration.""" + component = hass.data.get(DOMAIN) - yield from _async_process_config(hass, config, component) + if component is None: + component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) - descriptions = yield from hass.async_add_job( - conf_util.load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) + await _async_process_config(hass, config, component) - @asyncio.coroutine - def reload_service_handler(service): - """Remove all groups and load new ones from config.""" - conf = yield from component.async_prepare_reload() + 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 = 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) + + await component.async_add_entities(auto) hass.services.async_register( DOMAIN, SERVICE_RELOAD, reload_service_handler, - descriptions[DOMAIN][SERVICE_RELOAD], schema=RELOAD_SERVICE_SCHEMA) + 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) + group = component.get_entity(entity_id) # new group - if service.service == SERVICE_SET and object_id not in service_groups: + if service.service == SERVICE_SET and group is None: entity_ids = service.data.get(ATTR_ENTITIES) or \ service.data.get(ATTR_ADD_ENTITIES) or None @@ -285,30 +282,32 @@ def groups_service_handler(service): ATTR_VISIBLE, ATTR_ICON, ATTR_VIEW, ATTR_CONTROL ) if service.data.get(attr) is not None} - new_group = 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, user_defined=False, **extra_arg ) + return - service_groups[object_id] = new_group + if group is None: + _LOGGER.warning("%s:Group '%s' doesn't exist!", + service.service, object_id) return # update group if service.service == SERVICE_SET: - group = service_groups[object_id] need_update = False 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] @@ -331,29 +330,23 @@ def groups_service_handler(service): 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: - if object_id not in service_groups: - _LOGGER.warning("Group '%s' doesn't exist!", object_id) - return - - del_group = service_groups.pop(object_id) - yield from del_group.async_stop() + await component.async_remove_entity(entity_id) hass.services.async_register( DOMAIN, SERVICE_SET, groups_service_handler, - descriptions[DOMAIN][SERVICE_SET], schema=SET_SERVICE_SCHEMA) + schema=SET_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_REMOVE, groups_service_handler, - descriptions[DOMAIN][SERVICE_REMOVE], schema=REMOVE_SERVICE_SCHEMA) + 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) @@ -364,20 +357,17 @@ def visibility_service_handler(service): 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, - descriptions[DOMAIN][SERVICE_SET_VISIBILITY], schema=SET_VISIBILITY_SERVICE_SCHEMA) return True -@asyncio.coroutine -def _async_process_config(hass, config, component): +async def _async_process_config(hass, config, component): """Process group configuration.""" - groups = [] for object_id, conf in config.get(DOMAIN, {}).items(): name = conf.get(CONF_NAME, object_id) entity_ids = conf.get(CONF_ENTITIES) or [] @@ -387,20 +377,16 @@ 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. - group = 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) - groups.append(group) - - if groups: - yield from component.async_add_entities(groups) class Group(Entity): """Track a group of entity ids.""" def __init__(self, hass, name, order=None, visible=True, icon=None, - view=False, control=None, user_defined=True): + view=False, control=None, user_defined=True, entity_ids=None): """Initialize a group. This Object has factory function for creation. @@ -410,12 +396,15 @@ def __init__(self, hass, name, order=None, visible=True, icon=None, self._state = STATE_UNKNOWN self._icon = icon self.view = view - self.tracking = [] + if entity_ids: + self.tracking = tuple(ent_id.lower() for ent_id in entity_ids) + else: + self.tracking = tuple() self.group_on = None self.group_off = None self.visible = visible self.control = control - self._user_defined = user_defined + self.user_defined = user_defined self._order = order self._assumed_state = False self._async_unsub_state_changed = None @@ -432,10 +421,9 @@ def create_group(hass, name, entity_ids=None, user_defined=True, 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. @@ -444,17 +432,20 @@ def async_create_group(hass, name, entity_ids=None, user_defined=True, hass, name, order=len(hass.states.async_entity_ids(DOMAIN)), visible=visible, icon=icon, view=view, control=control, - user_defined=user_defined + user_defined=user_defined, entity_ids=entity_ids ) group.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id or name, hass=hass) - # run other async stuff - if entity_ids is not None: - yield from group.async_update_tracked_entity_ids(entity_ids) - else: - yield from group.async_update_ha_state(True) + # If called before the platform async_setup is called (test cases) + component = hass.data.get(DOMAIN) + + if component is None: + component = hass.data[DOMAIN] = \ + EntityComponent(_LOGGER, DOMAIN, hass) + + await component.async_add_entities([group], True) return group @@ -502,7 +493,7 @@ def state_attributes(self): ATTR_ENTITY_ID: self.tracking, ATTR_ORDER: self._order, } - if not self._user_defined: + if not self.user_defined: data[ATTR_AUTO] = True if self.view: data[ATTR_VIEW] = True @@ -521,23 +512,18 @@ def update_tracked_entity_ids(self, entity_ids): 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() - def start(self): - """Start tracking members.""" - self.hass.add_job(self.async_start) - @callback def async_start(self): """Start tracking members. @@ -549,37 +535,33 @@ def async_start(self): self.hass, self.tracking, self._async_state_changed_listener ) - def stop(self): - """Unregister the group from Home Assistant.""" - run_coroutine_threadsafe(self.async_stop(), self.hass.loop).result() - - @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. """ - yield from self.async_remove() + if self._async_unsub_state_changed: + 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() - def async_remove(self): - """Remove group from HASS. + async def async_added_to_hass(self): + """Callback when added to HASS.""" + if self.tracking: + self.async_start() - This method must be run in the event loop and returns a coroutine. - """ + 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 - return super().async_remove() - - @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. @@ -589,7 +571,7 @@ def _async_state_changed_listener(self, entity_id, old_state, new_state): 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/group/services.yaml b/homeassistant/components/group/services.yaml new file mode 100644 index 0000000000000..f51f8b909d43f --- /dev/null +++ b/homeassistant/components/group/services.yaml @@ -0,0 +1,50 @@ +# Describes the format for available group services + +reload: + description: Reload group configuration. + +set_visibility: + description: Hide or show a group. + fields: + entity_id: + description: Name(s) of entities to set value. + example: 'group.travel' + visible: + description: True if group should be shown or False if it should be hidden. + example: True + +set: + description: Create/Update a user group. + fields: + object_id: + description: Group id and part of entity id. + example: 'test_group' + name: + description: Name of group + example: 'My test group' + view: + description: Boolean for if the group is a view. + example: True + icon: + description: Name of icon for the group. + example: 'mdi:camera' + control: + description: Value for control the group control. + example: 'hidden' + visible: + description: If the group is visible on UI. + example: True + entities: + description: List of all members in the group. Not compatible with 'delta'. + example: domain.entity_id1, domain.entity_id2 + add_entities: + description: List of members they will change on group listening. + example: domain.entity_id1, domain.entity_id2 + +remove: + description: Remove a user group. + fields: + object_id: + description: Group id and part of entity id. + example: 'test_group' + diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py deleted file mode 100644 index 1ba599c72b42a..0000000000000 --- a/homeassistant/components/hassio.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -Exposes regular REST commands as services. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/hassio/ -""" -import asyncio -import logging -import os -import re - -import aiohttp -from aiohttp import web -from aiohttp.web_exceptions import HTTPBadGateway -from aiohttp.hdrs import CONTENT_TYPE -import async_timeout - -from homeassistant.const import CONTENT_TYPE_TEXT_PLAIN -from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.components.frontend import register_built_in_panel - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'hassio' -DEPENDENCIES = ['http'] - -NO_TIMEOUT = { - re.compile(r'^homeassistant/update$'), re.compile(r'^host/update$'), - re.compile(r'^supervisor/update$'), re.compile(r'^addons/[^/]*/update$'), - re.compile(r'^addons/[^/]*/install$') -} - -NO_AUTH = { - re.compile(r'^panel$'), re.compile(r'^addons/[^/]*/logo$') -} - - -@asyncio.coroutine -def async_setup(hass, config): - """Set up the HASSio component.""" - try: - host = os.environ['HASSIO'] - except KeyError: - _LOGGER.error("No HassIO supervisor detect!") - return False - - websession = async_get_clientsession(hass) - hassio = HassIO(hass.loop, websession, host) - - api_ok = yield from hassio.is_connected() - if not api_ok: - _LOGGER.error("Not connected with HassIO!") - return False - - hass.http.register_view(HassIOView(hassio)) - - if 'frontend' in hass.config.components: - register_built_in_panel(hass, 'hassio', 'Hass.io', - 'mdi:access-point-network') - - return True - - -class HassIO(object): - """Small API wrapper for HassIO.""" - - def __init__(self, loop, websession, ip): - """Initialze HassIO api.""" - self.loop = loop - self.websession = websession - self._ip = ip - - @asyncio.coroutine - def is_connected(self): - """Return True if it connected to HassIO supervisor. - - This method is a coroutine. - """ - try: - with async_timeout.timeout(10, loop=self.loop): - request = yield from self.websession.get( - "http://{}{}".format(self._ip, "/supervisor/ping") - ) - - if request.status != 200: - _LOGGER.error("Ping return code %d.", request.status) - return False - - answer = yield from request.json() - return answer and answer['result'] == 'ok' - - except asyncio.TimeoutError: - _LOGGER.error("Timeout on ping request") - - except aiohttp.ClientError as err: - _LOGGER.error("Client error on ping request %s", err) - - return False - - @asyncio.coroutine - def command_proxy(self, path, request): - """Return a client request with proxy origin for HassIO supervisor. - - This method is a coroutine. - """ - read_timeout = _get_timeout(path) - - try: - data = None - headers = None - with async_timeout.timeout(10, loop=self.loop): - data = yield from request.read() - if data: - headers = {CONTENT_TYPE: request.content_type} - else: - data = None - - method = getattr(self.websession, request.method.lower()) - client = yield from method( - "http://{}/{}".format(self._ip, path), data=data, - headers=headers, timeout=read_timeout - ) - - return client - - except aiohttp.ClientError as err: - _LOGGER.error("Client error on api %s request %s.", path, err) - - except asyncio.TimeoutError: - _LOGGER.error("Client timeout error on api request %s.", path) - - raise HTTPBadGateway() - - -class HassIOView(HomeAssistantView): - """HassIO view to handle base part.""" - - name = "api:hassio" - url = "/api/hassio/{path:.+}" - requires_auth = False - - def __init__(self, hassio): - """Initialize a hassio base view.""" - self.hassio = hassio - - @asyncio.coroutine - def _handle(self, request, path): - """Route data to hassio.""" - if _need_auth(path) and not request[KEY_AUTHENTICATED]: - return web.Response(status=401) - - client = yield from self.hassio.command_proxy(path, request) - - data = yield from client.read() - if path.endswith('/logs'): - return _create_response_log(client, data) - return _create_response(client, data) - - get = _handle - post = _handle - - -def _create_response(client, data): - """Convert a response from client request.""" - return web.Response( - body=data, - status=client.status, - content_type=client.content_type, - ) - - -def _create_response_log(client, data): - """Convert a response from client request.""" - # Remove color codes - log = re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", data.decode()) - - return web.Response( - text=log, - status=client.status, - content_type=CONTENT_TYPE_TEXT_PLAIN, - ) - - -def _get_timeout(path): - """Return timeout for a url path.""" - for re_path in NO_TIMEOUT: - if re_path.match(path): - return 0 - return 300 - - -def _need_auth(path): - """Return if a path need a auth.""" - for re_path in NO_AUTH: - if re_path.match(path): - return False - return True diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py new file mode 100644 index 0000000000000..aa24cc61af3c0 --- /dev/null +++ b/homeassistant/components/hassio/__init__.py @@ -0,0 +1,232 @@ +""" +Exposes regular REST commands as services. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hassio/ +""" +import asyncio +from datetime import timedelta +import logging +import os + +import voluptuous as vol + +from homeassistant.components import SERVICE_CHECK_CONFIG +from homeassistant.const import ( + 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 + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'hassio' +DEPENDENCIES = ['http'] + +DATA_HOMEASSISTANT_VERSION = 'hassio_hass_version' +HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) + +SERVICE_ADDON_START = 'addon_start' +SERVICE_ADDON_STOP = 'addon_stop' +SERVICE_ADDON_RESTART = 'addon_restart' +SERVICE_ADDON_STDIN = 'addon_stdin' +SERVICE_HOST_SHUTDOWN = 'host_shutdown' +SERVICE_HOST_REBOOT = 'host_reboot' +SERVICE_SNAPSHOT_FULL = 'snapshot_full' +SERVICE_SNAPSHOT_PARTIAL = 'snapshot_partial' +SERVICE_RESTORE_FULL = 'restore_full' +SERVICE_RESTORE_PARTIAL = 'restore_partial' + +ATTR_ADDON = 'addon' +ATTR_INPUT = 'input' +ATTR_SNAPSHOT = 'snapshot' +ATTR_ADDONS = 'addons' +ATTR_FOLDERS = 'folders' +ATTR_HOMEASSISTANT = 'homeassistant' +ATTR_PASSWORD = 'password' + +SCHEMA_NO_DATA = vol.Schema({}) + +SCHEMA_ADDON = vol.Schema({ + vol.Required(ATTR_ADDON): cv.slug, +}) + +SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend({ + vol.Required(ATTR_INPUT): vol.Any(dict, cv.string) +}) + +SCHEMA_SNAPSHOT_FULL = vol.Schema({ + vol.Optional(ATTR_NAME): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, +}) + +SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend({ + vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), +}) + +SCHEMA_RESTORE_FULL = vol.Schema({ + vol.Required(ATTR_SNAPSHOT): cv.slug, + vol.Optional(ATTR_PASSWORD): cv.string, +}) + +SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend({ + vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, + vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), +}) + +MAP_SERVICE_API = { + SERVICE_ADDON_START: ('/addons/{addon}/start', SCHEMA_ADDON, 60, False), + SERVICE_ADDON_STOP: ('/addons/{addon}/stop', SCHEMA_ADDON, 60, False), + SERVICE_ADDON_RESTART: + ('/addons/{addon}/restart', SCHEMA_ADDON, 60, False), + SERVICE_ADDON_STDIN: + ('/addons/{addon}/stdin', SCHEMA_ADDON_STDIN, 60, False), + SERVICE_HOST_SHUTDOWN: ('/host/shutdown', SCHEMA_NO_DATA, 60, False), + SERVICE_HOST_REBOOT: ('/host/reboot', SCHEMA_NO_DATA, 60, False), + SERVICE_SNAPSHOT_FULL: + ('/snapshots/new/full', SCHEMA_SNAPSHOT_FULL, 300, True), + SERVICE_SNAPSHOT_PARTIAL: + ('/snapshots/new/partial', SCHEMA_SNAPSHOT_PARTIAL, 300, True), + SERVICE_RESTORE_FULL: + ('/snapshots/{snapshot}/restore/full', SCHEMA_RESTORE_FULL, 300, True), + SERVICE_RESTORE_PARTIAL: + ('/snapshots/{snapshot}/restore/partial', SCHEMA_RESTORE_PARTIAL, 300, + True), +} + + +@callback +@bind_hass +def get_homeassistant_version(hass): + """Return latest available Home Assistant version. + + Async friendly. + """ + return hass.data.get(DATA_HOMEASSISTANT_VERSION) + + +@callback +@bind_hass +def is_hassio(hass): + """Return true if hass.io is loaded. + + Async friendly. + """ + return DOMAIN in hass.config.components + + +@bind_hass +@asyncio.coroutine +def async_check_config(hass): + """Check configuration over Hass.io API.""" + hassio = hass.data[DOMAIN] + result = yield from hassio.check_homeassistant_config() + + if not result: + return "Hass.io config check API error" + elif result['result'] == "error": + return result['message'] + return None + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the Hass.io component.""" + try: + host = os.environ['HASSIO'] + except KeyError: + _LOGGER.error("No Hass.io supervisor detect") + return False + + websession = hass.helpers.aiohttp_client.async_get_clientsession() + hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) + + if not (yield from hassio.is_connected()): + _LOGGER.error("Not connected with Hass.io") + return 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:home-assistant') + + if 'http' in config: + yield from hassio.update_hass_api(config['http']) + + if 'homeassistant' in config: + yield from hassio.update_hass_timezone(config['homeassistant']) + + @asyncio.coroutine + def async_service_handler(service): + """Handle service calls for Hass.io.""" + api_command = MAP_SERVICE_API[service.service][0] + data = service.data.copy() + addon = data.pop(ATTR_ADDON, None) + snapshot = data.pop(ATTR_SNAPSHOT, None) + payload = None + + # Pass data to hass.io API + if service.service == SERVICE_ADDON_STDIN: + payload = data[ATTR_INPUT] + elif MAP_SERVICE_API[service.service][3]: + payload = data + + # Call API + ret = yield from hassio.send_command( + api_command.format(addon=addon, snapshot=snapshot), + payload=payload, timeout=MAP_SERVICE_API[service.service][2] + ) + + if not ret or ret['result'] != "ok": + _LOGGER.error("Error on Hass.io API: %s", ret['message']) + + for service, settings in MAP_SERVICE_API.items(): + hass.services.async_register( + DOMAIN, service, async_service_handler, schema=settings[1]) + + @asyncio.coroutine + def update_homeassistant_version(now): + """Update last available Home Assistant version.""" + data = yield from hassio.get_homeassistant_info() + if data: + hass.data[DATA_HOMEASSISTANT_VERSION] = data['last_version'] + + hass.helpers.event.async_track_point_in_utc_time( + update_homeassistant_version, utcnow() + HASSIO_UPDATE_INTERVAL) + + # Fetch last version + yield from update_homeassistant_version(None) + + @asyncio.coroutine + def async_handle_core_service(call): + """Service handler for handling core services.""" + if call.service == SERVICE_HOMEASSISTANT_STOP: + yield from hassio.stop_homeassistant() + return + + error = yield from async_check_config(hass) + if error: + _LOGGER.error(error) + hass.components.persistent_notification.async_create( + "Config error. See dev-info panel for details.", + "Config validating", "{0}.check_config".format(HASS_DOMAIN)) + return + + if call.service == SERVICE_HOMEASSISTANT_RESTART: + yield from hassio.restart_homeassistant() + + # Mock core services + for service in (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, + SERVICE_CHECK_CONFIG): + hass.services.async_register( + HASS_DOMAIN, service, async_handle_core_service) + + return True diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py new file mode 100644 index 0000000000000..c3caf40ba62a7 --- /dev/null +++ b/homeassistant/components/hassio/handler.py @@ -0,0 +1,154 @@ +""" +Exposes regular REST commands as services. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hassio/ +""" +import asyncio +import logging +import os + +import aiohttp +import async_timeout + +from homeassistant.components.http import ( + CONF_API_PASSWORD, CONF_SERVER_HOST, CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE) +from homeassistant.const import CONF_TIME_ZONE, SERVER_PORT + +_LOGGER = logging.getLogger(__name__) + +X_HASSIO = 'X-HASSIO-KEY' + + +def _api_bool(funct): + """Return a boolean.""" + @asyncio.coroutine + def _wrapper(*argv, **kwargs): + """Wrap function.""" + data = yield from funct(*argv, **kwargs) + return data and data['result'] == "ok" + + return _wrapper + + +def _api_data(funct): + """Return data of an api.""" + @asyncio.coroutine + def _wrapper(*argv, **kwargs): + """Wrap function.""" + data = yield from funct(*argv, **kwargs) + if data and data['result'] == "ok": + return data['data'] + return None + + return _wrapper + + +class HassIO(object): + """Small API wrapper for Hass.io.""" + + def __init__(self, loop, websession, ip): + """Initialize Hass.io API.""" + self.loop = loop + self.websession = websession + self._ip = ip + + @_api_bool + def is_connected(self): + """Return true if it connected to Hass.io supervisor. + + This method return a coroutine. + """ + return self.send_command("/supervisor/ping", method="get") + + @_api_data + def get_homeassistant_info(self): + """Return data for Home Assistant. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/info", method="get") + + @_api_bool + def restart_homeassistant(self): + """Restart Home-Assistant container. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/restart") + + @_api_bool + def stop_homeassistant(self): + """Stop Home-Assistant container. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/stop") + + def check_homeassistant_config(self): + """Check Home-Assistant config with Hass.io API. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/check", timeout=300) + + @_api_bool + def update_hass_api(self, http_config): + """Update Home Assistant API data on Hass.io. + + This method return a coroutine. + """ + port = http_config.get(CONF_SERVER_PORT) or SERVER_PORT + options = { + 'ssl': CONF_SSL_CERTIFICATE in http_config, + 'port': port, + 'password': http_config.get(CONF_API_PASSWORD), + 'watchdog': True, + } + + if CONF_SERVER_HOST in http_config: + options['watchdog'] = False + _LOGGER.warning("Don't use 'server_host' options with Hass.io") + + return self.send_command("/homeassistant/options", payload=options) + + @_api_bool + def update_hass_timezone(self, core_config): + """Update Home-Assistant timezone data on Hass.io. + + This method return a coroutine. + """ + return self.send_command("/supervisor/options", payload={ + 'timezone': core_config.get(CONF_TIME_ZONE) + }) + + @asyncio.coroutine + def send_command(self, command, method="post", payload=None, timeout=10): + """Send API command to Hass.io. + + This method is a coroutine. + """ + try: + with async_timeout.timeout(timeout, loop=self.loop): + request = yield from self.websession.request( + method, "http://{}{}".format(self._ip, command), + json=payload, headers={ + X_HASSIO: os.environ.get('HASSIO_TOKEN', "") + }) + + if request.status not in (200, 400): + _LOGGER.error( + "%s return code %d.", command, request.status) + return None + + answer = yield from request.json() + return answer + + except asyncio.TimeoutError: + _LOGGER.error("Timeout on %s request", command) + + except aiohttp.ClientError as err: + _LOGGER.error("Client error on %s request %s", command, err) + + return None diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py new file mode 100644 index 0000000000000..9dd6427ec38b1 --- /dev/null +++ b/homeassistant/components/hassio/http.py @@ -0,0 +1,142 @@ +""" +Exposes regular REST commands as services. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hassio/ +""" +import asyncio +import logging +import os +import re + +import async_timeout +import aiohttp +from aiohttp import web +from aiohttp.hdrs import CONTENT_TYPE +from aiohttp.web_exceptions import HTTPBadGateway + +from homeassistant.const import CONTENT_TYPE_TEXT_PLAIN +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView + +_LOGGER = logging.getLogger(__name__) + +X_HASSIO = 'X-HASSIO-KEY' + +NO_TIMEOUT = { + re.compile(r'^homeassistant/update$'), + re.compile(r'^host/update$'), + re.compile(r'^supervisor/update$'), + re.compile(r'^addons/[^/]*/update$'), + re.compile(r'^addons/[^/]*/install$'), + re.compile(r'^addons/[^/]*/rebuild$'), + re.compile(r'^snapshots/.*/full$'), + re.compile(r'^snapshots/.*/partial$'), + re.compile(r'^snapshots/[^/]*/upload$'), + re.compile(r'^snapshots/[^/]*/download$'), +} + +NO_AUTH = { + re.compile(r'^app-(es5|latest)/(index|hassio-app).html$'), + re.compile(r'^addons/[^/]*/logo$') +} + + +class HassIOView(HomeAssistantView): + """Hass.io view to handle base part.""" + + name = "api:hassio" + url = "/api/hassio/{path:.+}" + requires_auth = False + + def __init__(self, host, websession): + """Initialize a Hass.io base view.""" + self._host = host + self._websession = websession + + @asyncio.coroutine + def _handle(self, request, path): + """Route data to Hass.io.""" + if _need_auth(path) and not request[KEY_AUTHENTICATED]: + return web.Response(status=401) + + client = yield from self._command_proxy(path, request) + + data = yield from client.read() + if path.endswith('/logs'): + return _create_response_log(client, data) + return _create_response(client, data) + + get = _handle + post = _handle + + @asyncio.coroutine + def _command_proxy(self, path, request): + """Return a client request with proxy origin for Hass.io supervisor. + + This method is a coroutine. + """ + read_timeout = _get_timeout(path) + hass = request.app['hass'] + + try: + data = None + headers = {X_HASSIO: os.environ.get('HASSIO_TOKEN', "")} + with async_timeout.timeout(10, loop=hass.loop): + data = yield from request.read() + if data: + headers[CONTENT_TYPE] = request.content_type + else: + data = None + + method = getattr(self._websession, request.method.lower()) + client = yield from method( + "http://{}/{}".format(self._host, path), data=data, + headers=headers, timeout=read_timeout + ) + + return client + + except aiohttp.ClientError as err: + _LOGGER.error("Client error on api %s request %s", path, err) + + except asyncio.TimeoutError: + _LOGGER.error("Client timeout error on API request %s", path) + + raise HTTPBadGateway() + + +def _create_response(client, data): + """Convert a response from client request.""" + return web.Response( + body=data, + status=client.status, + content_type=client.content_type, + ) + + +def _create_response_log(client, data): + """Convert a response from client request.""" + # Remove color codes + log = re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", data.decode()) + + return web.Response( + text=log, + status=client.status, + content_type=CONTENT_TYPE_TEXT_PLAIN, + ) + + +def _get_timeout(path): + """Return timeout for a URL path.""" + for re_path in NO_TIMEOUT: + if re_path.match(path): + return 0 + return 300 + + +def _need_auth(path): + """Return if a path need authentication.""" + for re_path in NO_AUTH: + if re_path.match(path): + return False + return True diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index b4233f1ac82df..b5d64f48dc757 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -6,7 +6,6 @@ """ import logging import multiprocessing -import os from collections import defaultdict from functools import reduce @@ -16,7 +15,6 @@ from homeassistant.helpers import discovery from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER from homeassistant.components.switch import DOMAIN as SWITCH -from homeassistant.config import load_yaml_config_file from homeassistant.const import (EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP, STATE_ON, STATE_OFF, CONF_DEVICES, CONF_PLATFORM, @@ -37,7 +35,7 @@ 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 = { @@ -301,17 +299,12 @@ def _shutdown(call): def _start_cec(event): """Register services and start HDMI network to watch for devices.""" - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml'))[DOMAIN] hass.services.register(DOMAIN, SERVICE_SEND_COMMAND, _tx, - descriptions[SERVICE_SEND_COMMAND], SERVICE_SEND_COMMAND_SCHEMA) hass.services.register(DOMAIN, SERVICE_VOLUME, _volume, - descriptions[SERVICE_VOLUME], - SERVICE_VOLUME_SCHEMA) + schema=SERVICE_VOLUME_SCHEMA) hass.services.register(DOMAIN, SERVICE_UPDATE_DEVICES, _update, - descriptions[SERVICE_UPDATE_DEVICES], - SERVICE_UPDATE_DEVICES_SCHEMA) + schema=SERVICE_UPDATE_DEVICES_SCHEMA) hass.services.register(DOMAIN, SERVICE_POWER_ON, _power_on) hass.services.register(DOMAIN, SERVICE_STANDBY, _standby) hass.services.register(DOMAIN, SERVICE_SELECT_DEVICE, _select_device) @@ -327,7 +320,7 @@ def _start_cec(event): class CecDevice(Entity): """Representation of a HDMI CEC device entity.""" - def __init__(self, hass: HomeAssistant, device, logical): + def __init__(self, hass: HomeAssistant, device, logical) -> None: """Initialize the device.""" self._device = device self.hass = hass diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 5a3002c05f2a3..c27e394ce28e5 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -4,7 +4,6 @@ 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 @@ -17,18 +16,22 @@ HTTP_BAD_REQUEST, CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE) import homeassistant.util.dt as dt_util from homeassistant.components import recorder, script -from homeassistant.components.frontend import register_built_in_panel from homeassistant.components.http import HomeAssistantView from homeassistant.const import ATTR_HIDDEN from homeassistant.components.recorder.util import session_scope, execute +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DOMAIN = 'history' DEPENDENCIES = ['recorder', 'http'] +CONF_ORDER = 'use_include_order' + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: recorder.FILTER_SCHEMA, + DOMAIN: recorder.FILTER_SCHEMA.extend({ + vol.Optional(CONF_ORDER, default=False): cv.boolean, + }) }, extra=vol.ALLOW_EXTRA) SIGNIFICANT_DOMAINS = ('thermostat', 'climate') @@ -41,6 +44,7 @@ def last_recorder_run(hass): with session_scope(hass=hass) as session: res = (session.query(RecorderRuns) + .filter(RecorderRuns.end.isnot(None)) .order_by(RecorderRuns.end.desc()).first()) if res is None: return None @@ -48,8 +52,8 @@ def last_recorder_run(hass): return res -def get_significant_states(hass, start_time, end_time=None, entity_id=None, - filters=None): +def get_significant_states(hass, start_time, end_time=None, entity_ids=None, + filters=None, include_start_time_state=True): """ Return states changes during UTC period start_time - end_time. @@ -60,8 +64,6 @@ def get_significant_states(hass, start_time, end_time=None, entity_id=None, timer_start = time.perf_counter() from homeassistant.components.recorder.models import States - entity_ids = (entity_id.lower(), ) if entity_id is not None else None - with session_scope(hass=hass) as session: query = session.query(States).filter( (States.domain.in_(SIGNIFICANT_DOMAINS) | @@ -86,7 +88,9 @@ def get_significant_states(hass, start_time, end_time=None, entity_id=None, _LOGGER.debug( 'get_significant_states took %fs', elapsed) - return states_to_json(hass, states, start_time, entity_id, filters) + return states_to_json( + hass, states, start_time, entity_ids, filters, + include_start_time_state) def state_changes_during_period(hass, start_time, end_time=None, @@ -105,10 +109,36 @@ def state_changes_during_period(hass, start_time, end_time=None, 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)) - return states_to_json(hass, states, start_time, entity_id) + 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, @@ -185,7 +215,13 @@ def get_states(hass, utc_point_in_time, entity_ids=None, run=None, if not state.attributes.get(ATTR_HIDDEN, False)] -def states_to_json(hass, states, start_time, entity_id, filters=None): +def states_to_json( + hass, + states, + start_time, + entity_ids, + filters=None, + include_start_time_state=True): """Convert SQL results into JSON friendly data structure. This takes our state list and turns it into a JSON friendly data @@ -197,14 +233,13 @@ def states_to_json(hass, states, start_time, entity_id, filters=None): """ result = defaultdict(list) - entity_ids = [entity_id] if entity_id is not None else None - # Get the states at the start time timer_start = time.perf_counter() - for state in get_states(hass, start_time, entity_ids, filters=filters): - state.last_changed = start_time - state.last_updated = start_time - result[state.entity_id].append(state) + if include_start_time_state: + for state in get_states(hass, start_time, entity_ids, filters=filters): + state.last_changed = start_time + state.last_updated = start_time + result[state.entity_id].append(state) if _LOGGER.isEnabledFor(logging.DEBUG): elapsed = time.perf_counter() - timer_start @@ -223,21 +258,23 @@ def get_state(hass, utc_point_in_time, entity_id, run=None): return states[0] if states else None -# pylint: disable=unused-argument -def 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[CONF_ENTITIES] - filters.excluded_domains = exclude[CONF_DOMAINS] - include = config[DOMAIN].get(CONF_INCLUDE) + filters.excluded_entities = exclude.get(CONF_ENTITIES, []) + filters.excluded_domains = exclude.get(CONF_DOMAINS, []) + include = conf.get(CONF_INCLUDE) if include: - filters.included_entities = include[CONF_ENTITIES] - filters.included_domains = include[CONF_DOMAINS] + filters.included_entities = include.get(CONF_ENTITIES, []) + filters.included_domains = include.get(CONF_DOMAINS, []) + use_include_order = conf.get(CONF_ORDER) - hass.http.register_view(HistoryPeriodView(filters)) - register_built_in_panel(hass, 'history', 'History', 'mdi:poll-box') + hass.http.register_view(HistoryPeriodView(filters, use_include_order)) + await hass.components.frontend.async_register_built_in_panel( + 'history', 'history', 'mdi:poll-box') return True @@ -249,12 +286,12 @@ class HistoryPeriodView(HomeAssistantView): name = 'api:history:view-period' extra_urls = ['/api/history/period/{datetime}'] - def __init__(self, filters): - """Initilalize the history period view.""" + def __init__(self, filters, use_include_order): + """Initialize the history period view.""" 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: @@ -276,23 +313,44 @@ def get(self, request, datetime=None): end_time = request.query.get('end_time') if end_time: - end_time = dt_util.as_utc( - dt_util.parse_datetime(end_time)) - if end_time is None: + end_time = dt_util.parse_datetime(end_time) + if end_time: + end_time = dt_util.as_utc(end_time) + else: return self.json_message('Invalid end_time', HTTP_BAD_REQUEST) else: end_time = start_time + one_day - entity_id = request.query.get('filter_entity_id') + entity_ids = request.query.get('filter_entity_id') + if entity_ids: + entity_ids = entity_ids.lower().split(',') + include_start_time_state = 'skip_initial_state' not in request.query - result = yield from request.app['hass'].async_add_job( - get_significant_states, request.app['hass'], start_time, end_time, - entity_id, self.filters) - result = result.values() + hass = request.app['hass'] + + result = await hass.async_add_job( + get_significant_states, hass, start_time, end_time, + entity_ids, self.filters, include_start_time_state) + result = list(result.values()) if _LOGGER.isEnabledFor(logging.DEBUG): elapsed = time.perf_counter() - timer_start _LOGGER.debug( 'Extracted %d states in %fs', sum(map(len, result)), elapsed) - return self.json(result) + + # Optionally reorder the result to respect the ordering given + # by any entities explicitly included in the configuration. + + if self.use_include_order: + sorted_result = [] + for order_entity in self.filters.included_entities: + for state_list in result: + if state_list[0].entity_id == order_entity: + sorted_result.append(state_list) + result.remove(state_list) + break + sorted_result.extend(result) + result = sorted_result + + 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 new file mode 100644 index 0000000000000..fa7d615dce25c --- /dev/null +++ b/homeassistant/components/history_graph.py @@ -0,0 +1,85 @@ +""" +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 logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_ENTITIES, CONF_NAME, ATTR_ENTITY_ID +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent + +DEPENDENCIES = ['history'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'history_graph' + +CONF_HOURS_TO_SHOW = 'hours_to_show' +CONF_REFRESH = 'refresh' +ATTR_HOURS_TO_SHOW = CONF_HOURS_TO_SHOW +ATTR_REFRESH = CONF_REFRESH + + +GRAPH_SCHEMA = vol.Schema({ + vol.Required(CONF_ENTITIES): cv.entity_ids, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HOURS_TO_SHOW, default=24): vol.Range(min=1), + vol.Optional(CONF_REFRESH, default=0): vol.Range(min=0), +}) + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({cv.slug: GRAPH_SCHEMA}) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Load graph configurations.""" + component = EntityComponent( + _LOGGER, DOMAIN, hass) + graphs = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME, object_id) + graph = HistoryGraphEntity(name, cfg) + graphs.append(graph) + + await component.async_add_entities(graphs) + + return True + + +class HistoryGraphEntity(Entity): + """Representation of a graph entity.""" + + def __init__(self, name, cfg): + """Initialize the graph.""" + self._name = name + self._hours = cfg[CONF_HOURS_TO_SHOW] + self._refresh = cfg[CONF_REFRESH] + self._entities = cfg[CONF_ENTITIES] + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def state_attributes(self): + """Return the state attributes.""" + attrs = { + ATTR_HOURS_TO_SHOW: self._hours, + ATTR_REFRESH: self._refresh, + ATTR_ENTITY_ID: self._entities, + } + return attrs diff --git a/homeassistant/components/hive.py b/homeassistant/components/hive.py new file mode 100644 index 0000000000000..aa662fc2fb6d9 --- /dev/null +++ b/homeassistant/components/hive.py @@ -0,0 +1,84 @@ +""" +Support for the Hive devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hive/ +""" +import logging +import voluptuous as vol + +from homeassistant.const import (CONF_PASSWORD, CONF_SCAN_INTERVAL, + CONF_USERNAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform + +REQUIREMENTS = ['pyhiveapi==0.2.14'] + +_LOGGER = logging.getLogger(__name__) +DOMAIN = 'hive' +DATA_HIVE = 'data_hive' +DEVICETYPES = { + 'binary_sensor': 'device_list_binary_sensor', + 'climate': 'device_list_climate', + 'light': 'device_list_light', + 'switch': 'device_list_plug', + 'sensor': 'device_list_sensor', + } + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=2): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + + +class HiveSession: + """Initiate Hive Session Class.""" + + entities = [] + core = None + heating = None + hotwater = None + light = None + sensor = None + switch = None + weather = None + attributes = None + + +def setup(hass, config): + """Set up the Hive Component.""" + from pyhiveapi import Pyhiveapi + + session = HiveSession() + session.core = Pyhiveapi() + + username = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] + update_interval = config[DOMAIN][CONF_SCAN_INTERVAL] + + devicelist = session.core.initialise_api(username, + password, + update_interval) + + if devicelist is None: + _LOGGER.error("Hive API initialization failed") + return False + + session.sensor = Pyhiveapi.Sensor() + session.heating = Pyhiveapi.Heating() + 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(): + for key, devices in devicelist.items(): + if key == hive_type: + for hivedevice in devices: + load_platform(hass, ha_type, DOMAIN, hivedevice, config) + return True diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py new file mode 100644 index 0000000000000..41b0791a35278 --- /dev/null +++ b/homeassistant/components/homekit/__init__.py @@ -0,0 +1,230 @@ +"""Support for Apple HomeKit. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/homekit/ +""" +import ipaddress +import logging +from zlib import adler32 + +import voluptuous as vol + +from homeassistant.components.cover import ( + SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, + CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE) +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_FILTER, DEFAULT_PORT, + DEFAULT_AUTO_START, DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START, + DEVICE_CLASS_CO2, DEVICE_CLASS_PM25) +from .util import show_setup_message, validate_entity_config + +TYPES = Registry() +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['HAP-python==2.0.0'] + +# #### Driver Status #### +STATUS_READY = 0 +STATUS_RUNNING = 1 +STATUS_STOPPED = 2 +STATUS_WAIT = 3 + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All({ + 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) + + +async def async_setup(hass, config): + """Setup the HomeKit component.""" + _LOGGER.debug('Begin setup HomeKit') + + conf = config[DOMAIN] + 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, 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) + + return True + + +def get_accessory(hass, state, aid, config): + """Take state and return an accessory object if supported.""" + 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 + + a_type = None + name = config.get(CONF_NAME, 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': + a_type = 'Thermostat' + + elif state.domain == 'cover': + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + if device_class == 'garage' and \ + features & (SUPPORT_OPEN | SUPPORT_CLOSE): + a_type = 'GarageDoorOpener' + elif features & SUPPORT_SET_POSITION: + a_type = 'WindowCovering' + elif features & (SUPPORT_OPEN | 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 == '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 in ('switch', 'remote', 'input_boolean', '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, 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, ip_address, entity_filter, entity_config): + """Initialize a HomeKit object.""" + 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(self): + """Setup bridge and accessory driver.""" + from .accessories import HomeBridge, HomeDriver + + 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.bridge = HomeBridge(self.hass) + self.driver = HomeDriver(self.bridge, self._port, ip_addr, path) + + 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, state, aid, conf) + if acc is not None: + self.bridge.add_accessory(acc) + + def start(self, *args): + """Start the accessory driver.""" + if self.status != STATUS_READY: + return + self.status = STATUS_WAIT + + # pylint: disable=unused-variable + from . import ( # noqa F401 + type_covers, type_fans, type_lights, type_locks, + type_security_systems, type_sensors, type_switches, + type_thermostats) + + for state in self.hass.states.all(): + self.add_bridge_accessory(state) + self.bridge.set_driver(self.driver) + + if not self.bridge.paired: + show_setup_message(self.hass, self.bridge) + + _LOGGER.debug('Driver start') + self.hass.add_job(self.driver.start) + self.status = STATUS_RUNNING + + def stop(self, *args): + """Stop the accessory driver.""" + 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 new file mode 100644 index 0000000000000..7ec1fb542c911 --- /dev/null +++ b/homeassistant/components/homekit/accessories.py @@ -0,0 +1,134 @@ +"""Extend the basic Accessory and Bridge functions.""" +from datetime import timedelta +from functools import wraps +from inspect import getmodule +import logging + +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 ( + BRIDGE_MODEL, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, + DEBOUNCE_TIMEOUT, MANUFACTURER) +from .util import ( + show_setup_message, dismiss_setup_message) + +_LOGGER = logging.getLogger(__name__) + + +def debounce(func): + """Decorator function. Debounce callbacks form HomeKit.""" + @ha_callback + def call_later_listener(*args): + """Callback listener called from call_later.""" + # pylint: disable=unsubscriptable-object + nonlocal lastargs, remove_listener + hass = lastargs['hass'] + hass.async_add_job(func, *lastargs['args']) + lastargs = remove_listener = None + + @wraps(func) + def wrapper(*args): + """Wrapper starts async timer. + + The accessory must have 'self.hass' and 'self.entity_id' as attributes. + """ + # pylint: disable=not-callable + hass = args[0].hass + nonlocal lastargs, remove_listener + if remove_listener: + remove_listener() + lastargs = remove_listener = None + lastargs = {'hass': hass, 'args': [*args]} + remove_listener = track_point_in_utc_time( + hass, call_later_listener, + dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT)) + logger.debug('%s: Start %s timeout', args[0].entity_id, + func.__name__.replace('set_', '')) + + remove_listener = None + lastargs = None + name = getmodule(func).__name__ + logger = logging.getLogger(name) + return wrapper + + +class HomeAccessory(Accessory): + """Adapter class for Accessory.""" + + def __init__(self, hass, name, entity_id, aid, config, + category=CATEGORY_OTHER): + """Initialize a Accessory object.""" + super().__init__(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 + + def run(self): + """Method called by accessory after driver is started.""" + state = self.hass.states.get(self.entity_id) + self.update_state_callback(new_state=state) + async_track_state_change( + self.hass, self.entity_id, self.update_state_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.update_state(new_state) + + def update_state(self, new_state): + """Method called on state change to update HomeKit value. + + Overridden by accessory types. + """ + pass + + +class HomeBridge(Bridge): + """Adapter class for Bridge.""" + + def __init__(self, hass, name=BRIDGE_NAME): + """Initialize a Bridge object.""" + super().__init__(name) + self.set_info_service( + firmware_revision=__version__, manufacturer=MANUFACTURER, + model=BRIDGE_MODEL, serial_number=BRIDGE_SERIAL_NUMBER) + self.hass = hass + + def setup_message(self): + """Prevent print of pyhap setup message to terminal.""" + pass + + def add_paired_client(self, client_uuid, client_public): + """Override super function to dismiss setup message if paired.""" + super().add_paired_client(client_uuid, client_public) + dismiss_setup_message(self.hass) + + def remove_paired_client(self, client_uuid): + """Override super function to show setup message if unpaired.""" + super().remove_paired_client(client_uuid) + show_setup_message(self.hass, self) + + +class HomeDriver(AccessoryDriver): + """Adapter class for AccessoryDriver.""" + + def __init__(self, *args, **kwargs): + """Initialize a AccessoryDriver object.""" + super().__init__(*args, **kwargs) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py new file mode 100644 index 0000000000000..adde13cc03053 --- /dev/null +++ b/homeassistant/components/homekit/const.py @@ -0,0 +1,114 @@ +"""Constants used be the HomeKit component.""" +# #### MISC #### +DEBOUNCE_TIMEOUT = 0.5 +DOMAIN = 'homekit' +HOMEKIT_FILE = '.homekit.state' +HOMEKIT_NOTIFY_ID = 4663548 + +# #### CONFIG #### +CONF_AUTO_START = 'auto_start' +CONF_ENTITY_CONFIG = 'entity_config' +CONF_FILTER = 'filter' + +# #### CONFIG DEFAULTS #### +DEFAULT_AUTO_START = True +DEFAULT_PORT = 51827 + +# #### 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' + +# #### Services #### +SERV_ACCESSORY_INFO = 'AccessoryInformation' +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' # CurrentRelativeHumidity +SERV_LEAK_SENSOR = 'LeakSensor' +SERV_LIGHT_SENSOR = 'LightSensor' +SERV_LIGHTBULB = 'Lightbulb' # On | Brightness, Hue, Saturation, Name +SERV_LOCK = 'LockMechanism' +SERV_MOTION_SENSOR = 'MotionSensor' +SERV_OCCUPANCY_SENSOR = 'OccupancySensor' +SERV_SECURITY_SYSTEM = 'SecuritySystem' +SERV_SMOKE_SENSOR = 'SmokeSensor' +SERV_SWITCH = 'Switch' +SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' +SERV_THERMOSTAT = 'Thermostat' +SERV_WINDOW_COVERING = 'WindowCovering' +# CurrentPosition, TargetPosition, PositionState + +# #### Characteristics #### +CHAR_ACTIVE = 'Active' +CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity' +CHAR_AIR_QUALITY = 'AirQuality' +CHAR_BRIGHTNESS = 'Brightness' # Int | [0, 100] +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' # Int | [0, 100] +CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity' # percent +CHAR_CURRENT_SECURITY_STATE = 'SecuritySystemCurrentState' +CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' +CHAR_FIRMWARE_REVISION = 'FirmwareRevision' +CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature' +CHAR_HUE = 'Hue' # arcdegress | [0, 360] +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_ON = 'On' # boolean +CHAR_POSITION_STATE = 'PositionState' +CHAR_ROTATION_DIRECTION = 'RotationDirection' +CHAR_SATURATION = 'Saturation' # percent +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' # Int | [0, 100] +CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState' +CHAR_TARGET_TEMPERATURE = 'TargetTemperature' +CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits' + +# #### Properties #### +PROP_MAX_VALUE = 'maxValue' +PROP_MIN_VALUE = 'minValue' + +PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} + +# #### Device Class #### +DEVICE_CLASS_CO2 = 'co2' +DEVICE_CLASS_DOOR = 'door' +DEVICE_CLASS_GARAGE_DOOR = 'garage_door' +DEVICE_CLASS_GAS = 'gas' +DEVICE_CLASS_HUMIDITY = 'humidity' +DEVICE_CLASS_LIGHT = 'light' +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_TEMPERATURE = 'temperature' +DEVICE_CLASS_WINDOW = 'window' diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml new file mode 100644 index 0000000000000..e30e71301b3e0 --- /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/type_covers.py b/homeassistant/components/homekit/type_covers.py new file mode 100644 index 0000000000000..a32ba0370ec6d --- /dev/null +++ b/homeassistant/components/homekit/type_covers.py @@ -0,0 +1,159 @@ +"""Class to hold all cover accessories.""" +import logging + +from pyhap.const import CATEGORY_WINDOW_COVERING, CATEGORY_GARAGE_DOOR_OPENER + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP) +from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_SET_COVER_POSITION, STATE_OPEN, STATE_CLOSED, + SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_STOP_COVER, + ATTR_SUPPORTED_FEATURES) + +from . import TYPES +from .accessories import HomeAccessory, debounce +from .const import ( + SERV_WINDOW_COVERING, CHAR_CURRENT_POSITION, + CHAR_TARGET_POSITION, CHAR_POSITION_STATE, + SERV_GARAGE_DOOR_OPENER, CHAR_CURRENT_DOOR_STATE, CHAR_TARGET_DOOR_STATE) + +_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 + + if value == 0: + self.char_current_state.set_value(3) + self.hass.components.cover.open_cover(self.entity_id) + elif value == 1: + self.char_current_state.set_value(2) + self.hass.components.cover.close_cover(self.entity_id) + + 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) + + self.hass.services.call(DOMAIN, service, + {ATTR_ENTITY_ID: self.entity_id}) + + # 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 0000000000000..a3ea027c07e54 --- /dev/null +++ b/homeassistant/components/homekit/type_fans.py @@ -0,0 +1,116 @@ +"""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, STATE_OFF, STATE_ON, + SERVICE_TURN_OFF, SERVICE_TURN_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 0000000000000..dae3579a97a4a --- /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_HS_COLOR, ATTR_COLOR_TEMP, ATTR_BRIGHTNESS, ATTR_MIN_MIREDS, + ATTR_MAX_MIREDS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_BRIGHTNESS) +from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_OFF + +from . import TYPES +from .accessories import HomeAccessory, debounce +from .const import ( + SERV_LIGHTBULB, CHAR_COLOR_TEMPERATURE, + CHAR_BRIGHTNESS, CHAR_HUE, CHAR_ON, CHAR_SATURATION, + 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 + + if value == 1: + self.hass.components.light.turn_on(self.entity_id) + elif value == 0: + self.hass.components.light.turn_off(self.entity_id) + + @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.hass.components.light.turn_on( + self.entity_id, brightness_pct=value) + else: + self.hass.components.light.turn_off(self.entity_id) + + 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 + self.hass.components.light.turn_on(self.entity_id, color_temp=value) + + 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.""" + # Handle Color + 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}) + self.hass.components.light.turn_on( + self.entity_id, hs_color=color) + + 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 0000000000000..b08ac5930bd89 --- /dev/null +++ b/homeassistant/components/homekit/type_locks.py @@ -0,0 +1,69 @@ +"""Class to hold all lock accessories.""" +import logging + +from pyhap.const import CATEGORY_DOOR_LOCK + +from homeassistant.components.lock import ( + ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN) + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + SERV_LOCK, CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE) + +_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.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} + self.hass.services.call('lock', 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_security_systems.py b/homeassistant/components/homekit/type_security_systems.py new file mode 100644 index 0000000000000..bd29453e10a3d --- /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.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, ATTR_ENTITY_ID, ATTR_CODE) + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + SERV_SECURITY_SYSTEM, CHAR_CURRENT_SECURITY_STATE, + CHAR_TARGET_SECURITY_STATE) + +_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('alarm_control_panel', 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 0000000000000..0005c6184ee03 --- /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_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, + ATTR_DEVICE_CLASS, STATE_ON, STATE_HOME) + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + SERV_HUMIDITY_SENSOR, SERV_TEMPERATURE_SENSOR, + CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS, + SERV_AIR_QUALITY_SENSOR, CHAR_AIR_QUALITY, CHAR_AIR_PARTICULATE_DENSITY, + CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL, + SERV_LIGHT_SENSOR, CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, + DEVICE_CLASS_CO2, SERV_CARBON_DIOXIDE_SENSOR, CHAR_CARBON_DIOXIDE_DETECTED, + 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_DOOR, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_WINDOW, + DEVICE_CLASS_SMOKE, SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED) +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 is not None: + 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 is not None: + 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 is not None: + 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 0000000000000..ff4bf1611b8e1 --- /dev/null +++ b/homeassistant/components/homekit/type_switches.py @@ -0,0 +1,47 @@ +"""Class to hold all switch accessories.""" +import logging + +from pyhap.const import CATEGORY_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 SERV_SWITCH, CHAR_ON + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register('Switch') +class Switch(HomeAccessory): + """Generate a Switch accessory.""" + + def __init__(self, *args): + """Initialize a Switch accessory object to represent a remote.""" + 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 + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + self.hass.services.call(self._domain, service, + {ATTR_ENTITY_ID: self.entity_id}) + + 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 0000000000000..ab4d7faf8750f --- /dev/null +++ b/homeassistant/components/homekit/type_thermostats.py @@ -0,0 +1,240 @@ +"""Class to hold all thermostat accessories.""" +import logging + +from pyhap.const import CATEGORY_THERMOSTAT + +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, SUPPORT_ON_OFF, + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, + STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) + +from . import TYPES +from .accessories import HomeAccessory, debounce +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) +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 = TEMP_CELSIUS + 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 + + # Add additional characteristics if auto mode is supported + self.chars = [] + features = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SUPPORTED_FEATURES) + 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, + 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, + 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, + setter_callback=self.set_heating_threshold) + + 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('climate', 'turn_off', params) + return + else: + self.hass.services.call('climate', 'turn_on', params) + self.hass.components.climate.set_operation_mode( + operation_mode=hass_value, entity_id=self.entity_id) + + @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 + low = temperature_to_states(low, self._unit) + value = temperature_to_states(value, self._unit) + self.hass.components.climate.set_temperature( + entity_id=self.entity_id, target_temp_high=value, + target_temp_low=low) + + @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 + # Home assistant always wants to set low and high at the same time + high = self.char_cooling_thresh_temp.value + high = temperature_to_states(high, self._unit) + value = temperature_to_states(value, self._unit) + self.hass.components.climate.set_temperature( + entity_id=self.entity_id, target_temp_high=high, + target_temp_low=value) + + @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 + value = temperature_to_states(value, self._unit) + self.hass.components.climate.set_temperature( + temperature=value, entity_id=self.entity_id) + + def update_state(self, new_state): + """Update security state after state changed.""" + self._unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS) + + # 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 0000000000000..5ddef53420252 --- /dev/null +++ b/homeassistant/components/homekit/util.py @@ -0,0 +1,84 @@ +"""Collection of useful functions for the HomeKit component.""" +import logging + +import voluptuous as vol + +from homeassistant.core import split_entity_id +from homeassistant.const import ( + ATTR_CODE, CONF_NAME, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv +import homeassistant.util.temperature as temp_util +from .const import HOMEKIT_NOTIFY_ID + +_LOGGER = logging.getLogger(__name__) + + +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) + params = {} + if not isinstance(config, dict): + raise vol.Invalid('The configuration for "{}" must be ' + ' a dictionary.'.format(entity)) + + for key in (CONF_NAME, ): + value = config.get(key, -1) + if value != -1: + params[key] = cv.string(value) + + domain, _ = split_entity_id(entity) + + if domain == 'alarm_control_panel': + code = config.get(ATTR_CODE) + params[ATTR_CODE] = cv.string(code) if code else None + + entities[entity] = params + return entities + + +def show_setup_message(hass, bridge): + """Display persistent notification with setup information.""" + pin = bridge.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 0000000000000..e36e7439e09d6 --- /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.py b/homeassistant/components/homematic/__init__.py similarity index 74% rename from homeassistant/components/homematic.py rename to homeassistant/components/homematic/__init__.py index dc5e641cbbaab..aa19875d43aca 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic/__init__.py @@ -5,23 +5,24 @@ https://home-assistant.io/components/homematic/ """ import asyncio -import os -import logging from datetime import timedelta from functools import partial +import logging +import socket import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN, CONF_USERNAME, CONF_PASSWORD, - CONF_PLATFORM, CONF_HOSTS, CONF_NAME, ATTR_ENTITY_ID) + 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 +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.config import load_yaml_config_file +from homeassistant.loader import bind_hass -REQUIREMENTS = ['pyhomematic==0.1.30'] +REQUIREMENTS = ['pyhomematic==0.1.42'] + +_LOGGER = logging.getLogger(__name__) DOMAIN = 'homematic' @@ -34,16 +35,18 @@ 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_PROXY = 'proxy' +ATTR_INTERFACE = 'interface' ATTR_ERRORCODE = 'error' ATTR_MESSAGE = 'message' +ATTR_MODE = 'mode' +ATTR_TIME = 'time' EVENT_KEYPRESS = 'homematic.keypress' EVENT_IMPULSE = 'homematic.impulse' @@ -51,13 +54,14 @@ SERVICE_VIRTUALKEY = 'virtualkey' SERVICE_RECONNECT = 'reconnect' -SERVICE_SET_VAR_VALUE = 'set_var_value' -SERVICE_SET_DEV_VALUE = 'set_dev_value' +SERVICE_SET_VARIABLE_VALUE = 'set_variable_value' +SERVICE_SET_DEVICE_VALUE = 'set_device_value' +SERVICE_SET_INSTALL_MODE = 'set_install_mode' HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ - 'Switch', 'SwitchPowermeter', 'IOSwitch', 'IPSwitch', - 'IPSwitchPowermeter', 'KeyMatic', 'HMWIOSwitch', 'Rain', 'EcoLogic'], + 'Switch', 'SwitchPowermeter', 'IOSwitch', 'IPSwitch', 'RFSiren', + 'IPSwitchPowermeter', 'HMWIOSwitch', 'Rain', 'EcoLogic'], DISCOVER_LIGHTS: ['Dimmer', 'KeyDimmer', 'IPKeyDimmer'], DISCOVER_SENSORS: [ 'SwitchPowermeter', 'Motion', 'MotionV2', 'RemoteMotion', 'MotionIP', @@ -65,15 +69,20 @@ 'WaterSensor', 'PowermeterGas', 'LuxSensor', 'WeatherSensor', 'WeatherStation', 'ThermostatWall2', 'TemperatureDiffSensor', 'TemperatureSensor', 'CO2Sensor', 'IPSwitchPowermeter', 'HMWIOSwitch', - 'FillingLevel', 'ValveDrive', 'EcoLogic'], + 'FillingLevel', 'ValveDrive', 'EcoLogic', 'IPThermostatWall', + 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', + 'IPWeatherSensor'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', - 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall'], + 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', + 'ThermostatGroup'], DISCOVER_BINARY_SENSORS: [ 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', - 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', - 'HMWIOSwitch', 'MaxShutterContact', 'Rain', 'WiredSensor'], - DISCOVER_COVER: ['Blind', 'KeyBlind'] + 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', + 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', + 'WiredSensor', 'PresenceIP', 'IPWeatherSensor'], + DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'], + DISCOVER_LOCKS: ['KeyMatic'] } HM_IGNORE_DISCOVERY_NODE = [ @@ -81,21 +90,29 @@ 'ACTUAL_HUMIDITY' ] +HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS = { + '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'}], 'RSSI_DEVICE': ['rssi', {}], 'VALVE_STATE': ['valve', {}], 'BATTERY_STATE': ['battery', {}], - 'CONTROL_MODE': ['mode', {0: 'Auto', - 1: 'Manual', - 2: 'Away', - 3: 'Boost', - 4: 'Comfort', - 5: 'Lowering'}], + 'CONTROL_MODE': ['mode', { + 0: 'Auto', + 1: 'Manual', + 2: 'Away', + 3: 'Boost', + 4: 'Comfort', + 5: 'Lowering' + }], 'POWER': ['power', {}], 'CURRENT': ['current', {}], 'VOLTAGE': ['voltage', {}], + 'OPERATING_VOLTAGE': ['voltage', {}], 'WORKING': ['working', {0: 'No', 1: 'Yes'}], } @@ -111,8 +128,6 @@ 'SEQUENCE_OK', ] -_LOGGER = logging.getLogger(__name__) - CONF_RESOLVENAMES_OPTIONS = [ 'metadata', 'json', @@ -121,13 +136,14 @@ ] DATA_HOMEMATIC = 'homematic' -DATA_DEVINIT = 'homematic_devinit' DATA_STORE = 'homematic_store' +DATA_CONF = 'homematic_conf' +CONF_INTERFACES = 'interfaces' CONF_LOCAL_IP = 'local_ip' CONF_LOCAL_PORT = 'local_port' -CONF_IP = 'ip' CONF_PORT = 'port' +CONF_PATH = 'path' CONF_CALLBACK_IP = 'callback_ip' CONF_CALLBACK_PORT = 'callback_port' CONF_RESOLVENAMES = 'resolvenames' @@ -139,41 +155,40 @@ DEFAULT_LOCAL_PORT = 0 DEFAULT_RESOLVENAMES = False DEFAULT_PORT = 2001 +DEFAULT_PATH = '' DEFAULT_USERNAME = 'Admin' DEFAULT_PASSWORD = '' -DEFAULT_VARIABLES = False -DEFAULT_DEVICES = True -DEFAULT_PRIMARY = False DEVICE_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'homematic', vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_ADDRESS): cv.string, - vol.Required(ATTR_PROXY): cv.string, + vol.Required(ATTR_INTERFACE): cv.string, vol.Optional(ATTR_CHANNEL, default=1): vol.Coerce(int), vol.Optional(ATTR_PARAM): cv.string, }) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOSTS): {cv.match_all: { - vol.Required(CONF_IP): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): - cv.port, - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, - vol.Optional(CONF_VARIABLES, default=DEFAULT_VARIABLES): - cv.boolean, + vol.Optional(CONF_INTERFACES, default={}): {cv.match_all: { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, vol.Optional(CONF_RESOLVENAMES, default=DEFAULT_RESOLVENAMES): vol.In(CONF_RESOLVENAMES_OPTIONS), - vol.Optional(CONF_DEVICES, default=DEFAULT_DEVICES): cv.boolean, - vol.Optional(CONF_PRIMARY, default=DEFAULT_PRIMARY): cv.boolean, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_CALLBACK_IP): cv.string, vol.Optional(CONF_CALLBACK_PORT): cv.port, }}, + vol.Optional(CONF_HOSTS, default={}): {cv.match_all: { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + }}, vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string, - vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port, + vol.Optional(CONF_LOCAL_PORT): cv.port, }), }, extra=vol.ALLOW_EXTRA) @@ -181,61 +196,88 @@ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), vol.Required(ATTR_CHANNEL): vol.Coerce(int), vol.Required(ATTR_PARAM): cv.string, - vol.Optional(ATTR_PROXY): cv.string, + vol.Optional(ATTR_INTERFACE): cv.string, }) -SCHEMA_SERVICE_SET_VAR_VALUE = vol.Schema({ +SCHEMA_SERVICE_SET_VARIABLE_VALUE = vol.Schema({ vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_VALUE): cv.match_all, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) -SCHEMA_SERVICE_SET_DEV_VALUE = vol.Schema({ +SCHEMA_SERVICE_SET_DEVICE_VALUE = vol.Schema({ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), vol.Required(ATTR_CHANNEL): vol.Coerce(int), vol.Required(ATTR_PARAM): vol.All(cv.string, vol.Upper), vol.Required(ATTR_VALUE): cv.match_all, - vol.Optional(ATTR_PROXY): cv.string, + vol.Optional(ATTR_INTERFACE): cv.string, }) SCHEMA_SERVICE_RECONNECT = vol.Schema({}) +SCHEMA_SERVICE_SET_INSTALL_MODE = vol.Schema({ + vol.Required(ATTR_INTERFACE): cv.string, + vol.Optional(ATTR_TIME, default=60): cv.positive_int, + vol.Optional(ATTR_MODE, default=1): + vol.All(vol.Coerce(int), vol.In([1, 2])), + vol.Optional(ATTR_ADDRESS): vol.All(cv.string, vol.Upper), +}) + -def virtualkey(hass, address, channel, param, proxy=None): - """Send virtual keypress to homematic controlller.""" +@bind_hass +def virtualkey(hass, address, channel, param, interface=None): + """Send virtual keypress to homematic controller.""" data = { ATTR_ADDRESS: address, ATTR_CHANNEL: channel, ATTR_PARAM: param, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, } hass.services.call(DOMAIN, SERVICE_VIRTUALKEY, data) -def set_var_value(hass, entity_id, value): +@bind_hass +def set_variable_value(hass, entity_id, value): """Change value of a Homematic system variable.""" data = { ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value, } - hass.services.call(DOMAIN, SERVICE_SET_VAR_VALUE, data) + hass.services.call(DOMAIN, SERVICE_SET_VARIABLE_VALUE, data) -def set_dev_value(hass, address, channel, param, value, proxy=None): - """Call setValue XML-RPC method of supplied proxy.""" +@bind_hass +def set_device_value(hass, address, channel, param, value, interface=None): + """Call setValue XML-RPC method of supplied interface.""" data = { ATTR_ADDRESS: address, ATTR_CHANNEL: channel, ATTR_PARAM: param, ATTR_VALUE: value, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, } - hass.services.call(DOMAIN, SERVICE_SET_DEV_VALUE, data) + hass.services.call(DOMAIN, SERVICE_SET_DEVICE_VALUE, data) + + +@bind_hass +def set_install_mode(hass, interface, mode=None, time=None, address=None): + """Call setInstallMode XML-RPC method of supplied interface.""" + data = { + key: value for key, value in ( + (ATTR_INTERFACE, interface), + (ATTR_MODE, mode), + (ATTR_TIME, time), + (ATTR_ADDRESS, address) + ) if value + } + hass.services.call(DOMAIN, SERVICE_SET_INSTALL_MODE, data) + +@bind_hass def reconnect(hass): """Reconnect to CCU/Homegear.""" hass.services.call(DOMAIN, SERVICE_RECONNECT, {}) @@ -245,36 +287,38 @@ def setup(hass, config): """Set up the Homematic component.""" from pyhomematic import HMConnection - hass.data[DATA_DEVINIT] = {} + conf = config[DOMAIN] + hass.data[DATA_CONF] = remotes = {} hass.data[DATA_STORE] = set() # Create hosts-dictionary for pyhomematic - remotes = {} - hosts = {} - for rname, rconfig in config[DOMAIN][CONF_HOSTS].items(): - server = rconfig.get(CONF_IP) - - remotes[rname] = {} - remotes[rname][CONF_IP] = server - remotes[rname][CONF_PORT] = rconfig.get(CONF_PORT) - remotes[rname][CONF_RESOLVENAMES] = rconfig.get(CONF_RESOLVENAMES) - remotes[rname][CONF_USERNAME] = rconfig.get(CONF_USERNAME) - remotes[rname][CONF_PASSWORD] = rconfig.get(CONF_PASSWORD) - remotes[rname]['callbackip'] = rconfig.get(CONF_CALLBACK_IP) - remotes[rname]['callbackport'] = rconfig.get(CONF_CALLBACK_PORT) - - if server not in hosts or rconfig.get(CONF_PRIMARY): - hosts[server] = { - CONF_VARIABLES: rconfig.get(CONF_VARIABLES), - CONF_NAME: rname, - } - hass.data[DATA_DEVINIT][rname] = rconfig.get(CONF_DEVICES) + for rname, rconfig in conf[CONF_INTERFACES].items(): + remotes[rname] = { + 'ip': socket.gethostbyname(rconfig.get(CONF_HOST)), + 'port': rconfig.get(CONF_PORT), + 'path': rconfig.get(CONF_PATH), + 'resolvenames': rconfig.get(CONF_RESOLVENAMES), + 'username': rconfig.get(CONF_USERNAME), + 'password': rconfig.get(CONF_PASSWORD), + 'callbackip': rconfig.get(CONF_CALLBACK_IP), + 'callbackport': rconfig.get(CONF_CALLBACK_PORT), + 'connect': True, + } + + for sname, sconfig in conf[CONF_HOSTS].items(): + remotes[sname] = { + 'ip': socket.gethostbyname(sconfig.get(CONF_HOST)), + 'port': DEFAULT_PORT, + 'username': sconfig.get(CONF_USERNAME), + 'password': sconfig.get(CONF_PASSWORD), + 'connect': False, + } # Create server thread bound_system_callback = partial(_system_callback_handler, hass, config) hass.data[DATA_HOMEMATIC] = homematic = HMConnection( local=config[DOMAIN].get(CONF_LOCAL_IP), - localport=config[DOMAIN].get(CONF_LOCAL_PORT), + localport=config[DOMAIN].get(CONF_LOCAL_PORT, DEFAULT_LOCAL_PORT), remotes=remotes, systemcallback=bound_system_callback, interface_id='homeassistant' @@ -289,13 +333,8 @@ def setup(hass, config): # Init homematic hubs entity_hubs = [] - for _, hub_data in hosts.items(): - entity_hubs.append(HMHub( - homematic, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) - - # Register HomeMatic services - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) + for hub_name in conf[CONF_HOSTS].keys(): + entity_hubs.append(HMHub(hass, homematic, hub_name)) def _hm_service_virtualkey(service): """Service to handle virtualkey servicecalls.""" @@ -325,7 +364,6 @@ def _hm_service_virtualkey(service): hass.services.register( DOMAIN, SERVICE_VIRTUALKEY, _hm_service_virtualkey, - descriptions[DOMAIN][SERVICE_VIRTUALKEY], schema=SCHEMA_SERVICE_VIRTUALKEY) def _service_handle_value(service): @@ -348,9 +386,8 @@ def _service_handle_value(service): hub.hm_set_variable(name, value) hass.services.register( - DOMAIN, SERVICE_SET_VAR_VALUE, _service_handle_value, - descriptions[DOMAIN][SERVICE_SET_VAR_VALUE], - schema=SCHEMA_SERVICE_SET_VAR_VALUE) + DOMAIN, SERVICE_SET_VARIABLE_VALUE, _service_handle_value, + schema=SCHEMA_SERVICE_SET_VARIABLE_VALUE) def _service_handle_reconnect(service): """Service to reconnect all HomeMatic hubs.""" @@ -358,7 +395,6 @@ def _service_handle_reconnect(service): hass.services.register( DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect, - descriptions[DOMAIN][SERVICE_RECONNECT], schema=SCHEMA_SERVICE_RECONNECT) def _service_handle_device(service): @@ -377,9 +413,21 @@ def _service_handle_device(service): hmdevice.setValue(param, value, channel) hass.services.register( - DOMAIN, SERVICE_SET_DEV_VALUE, _service_handle_device, - descriptions[DOMAIN][SERVICE_SET_DEV_VALUE], - schema=SCHEMA_SERVICE_SET_DEV_VALUE) + DOMAIN, SERVICE_SET_DEVICE_VALUE, _service_handle_device, + schema=SCHEMA_SERVICE_SET_DEVICE_VALUE) + + def _service_handle_install_mode(service): + """Service to set interface into install mode.""" + interface = service.data.get(ATTR_INTERFACE) + mode = service.data.get(ATTR_MODE) + time = service.data.get(ATTR_TIME) + address = service.data.get(ATTR_ADDRESS) + + homematic.setInstallMode(interface, t=time, mode=mode, address=address) + + hass.services.register( + DOMAIN, SERVICE_SET_INSTALL_MODE, _service_handle_install_mode, + schema=SCHEMA_SERVICE_SET_INSTALL_MODE) return True @@ -389,10 +437,10 @@ def _system_callback_handler(hass, config, src, *args): # New devices available at hub if src == 'newDevices': (interface_id, dev_descriptions) = args - proxy = interface_id.split('-')[-1] + interface = interface_id.split('-')[-1] # Device support active? - if not hass.data[DATA_DEVINIT][proxy]: + if not hass.data[DATA_CONF][interface]['connect']: return addresses = [] @@ -404,9 +452,9 @@ def _system_callback_handler(hass, config, src, *args): # Register EVENTS # Search all devices with an EVENTNODE that includes data - bound_event_callback = partial(_hm_event_handler, hass, proxy) + bound_event_callback = partial(_hm_event_handler, hass, interface) for dev in addresses: - hmdevice = hass.data[DATA_HOMEMATIC].devices[proxy].get(dev) + hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(dev) if hmdevice.EVENTNODE: hmdevice.setEventCallback( @@ -420,13 +468,14 @@ 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, proxy) + hass, discovery_type, addresses, interface) # When devices of this type are found - # they are setup in HASS and an discovery event is fired + # they are setup in HASS and a discovery event is fired if found_devices: discovery.load_platform(hass, component_name, DOMAIN, { ATTR_DISCOVER_DEVICES: found_devices @@ -442,12 +491,12 @@ def _system_callback_handler(hass, config, src, *args): }) -def _get_devices(hass, discovery_type, keys, proxy): +def _get_devices(hass, discovery_type, keys, interface): """Get the HomeMatic devices for given discovery_type.""" device_arr = [] for key in keys: - device = hass.data[DATA_HOMEMATIC].devices[proxy][key] + device = hass.data[DATA_HOMEMATIC].devices[interface][key] class_name = device.__class__.__name__ metadata = {} @@ -465,7 +514,8 @@ def _get_devices(hass, discovery_type, keys, proxy): # Generate options for 1...n elements with 1...n parameters for param, channels in metadata.items(): - if param in HM_IGNORE_DISCOVERY_NODE: + if param in HM_IGNORE_DISCOVERY_NODE and class_name not in \ + HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS.get(param, []): continue # Add devices @@ -479,7 +529,7 @@ def _get_devices(hass, discovery_type, keys, proxy): device_dict = { CONF_PLATFORM: "homematic", ATTR_ADDRESS: key, - ATTR_PROXY: proxy, + ATTR_INTERFACE: interface, ATTR_NAME: name, ATTR_CHANNEL: channel } @@ -515,12 +565,12 @@ def _create_ha_name(name, channel, param, count): return "{} {} {}".format(name, channel, param) -def _hm_event_handler(hass, proxy, device, caller, attribute, value): +def _hm_event_handler(hass, interface, device, caller, attribute, value): """Handle all pyhomematic device events.""" try: channel = int(device.split(":")[1]) address = device.split(":")[0] - hmdevice = hass.data[DATA_HOMEMATIC].devices[proxy].get(address) + hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(address) except (TypeError, ValueError): _LOGGER.error("Event handling channel convert error!") return @@ -555,14 +605,14 @@ def _hm_event_handler(hass, proxy, device, caller, attribute, value): def _device_from_servicecall(hass, service): """Extract HomeMatic device from service call.""" address = service.data.get(ATTR_ADDRESS) - proxy = service.data.get(ATTR_PROXY) + interface = service.data.get(ATTR_INTERFACE) if address == 'BIDCOS-RF': address = 'BidCoS-RF' - if proxy: - return hass.data[DATA_HOMEMATIC].devices[proxy].get(address) + if interface: + return hass.data[DATA_HOMEMATIC].devices[interface].get(address) - for _, devices in hass.data[DATA_HOMEMATIC].devices.items(): + for devices in hass.data[DATA_HOMEMATIC].devices.values(): if address in devices: return devices[address] @@ -570,27 +620,23 @@ def _device_from_servicecall(hass, service): class HMHub(Entity): """The HomeMatic hub. (CCU2/HomeGear).""" - def __init__(self, homematic, name, use_variables): + def __init__(self, hass, homematic, name): """Initialize HomeMatic hub.""" + self.hass = hass self.entity_id = "{}.{}".format(DOMAIN, name.lower()) self._homematic = homematic self._variables = {} self._name = name - self._state = STATE_UNKNOWN - self._use_variables = use_variables + self._state = None - @asyncio.coroutine - def async_added_to_hass(self): - """Load data init callbacks.""" # Load data - async_track_time_interval( - self.hass, self._update_hub, SCAN_INTERVAL_HUB) - yield from self.hass.async_add_job(self._update_hub, None) + self.hass.helpers.event.track_time_interval( + self._update_hub, SCAN_INTERVAL_HUB) + self.hass.add_job(self._update_hub, None) - if self._use_variables: - async_track_time_interval( - self.hass, self._update_variables, SCAN_INTERVAL_VARIABLES) - yield from self.hass.async_add_job(self._update_variables, None) + self.hass.helpers.event.track_time_interval( + self._update_variables, SCAN_INTERVAL_VARIABLES) + self.hass.add_job(self._update_variables, None) @property def name(self): @@ -620,14 +666,16 @@ def icon(self): def _update_hub(self, now): """Retrieve latest state.""" - state = self._homematic.getServiceMessages(self._name) - self._state = STATE_UNKNOWN if state is None else len(state) + service_message = self._homematic.getServiceMessages(self._name) + state = None if service_message is None else len(service_message) - if now: + # state have change? + if self._state != state: + self._state = state self.schedule_update_ha_state() def _update_variables(self, now): - """Retrive all variable data and update hmvariable states.""" + """Retrieve all variable data and update hmvariable states.""" variables = self._homematic.getAllSystemVariables(self._name) if variables is None: return @@ -640,7 +688,7 @@ def _update_variables(self, now): state_change = True self._variables.update({key: value}) - if state_change and now: + if state_change: self.schedule_update_ha_state() def hm_set_variable(self, name, value): @@ -666,7 +714,7 @@ def __init__(self, config): """Initialize a generic HomeMatic device.""" self._name = config.get(ATTR_NAME) self._address = config.get(ATTR_ADDRESS) - self._proxy = config.get(ATTR_PROXY) + self._interface = config.get(ATTR_INTERFACE) self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) self._data = {} @@ -694,11 +742,6 @@ def name(self): """Return the name of the device.""" return self._name - @property - def assumed_state(self): - """Return true if unable to access real state of the device.""" - return not self._available - @property def available(self): """Return true if device is available.""" @@ -709,10 +752,6 @@ def device_state_attributes(self): """Return device specific state attributes.""" attr = {} - # No data available - if not self.available: - return attr - # Generate a dictionary with attributes for node, data in HM_ATTRIBUTE_SUPPORT.items(): # Is an attribute and exists for this object @@ -722,7 +761,7 @@ def device_state_attributes(self): # Static attributes attr['id'] = self._hmdevice.ADDRESS - attr['proxy'] = self._proxy + attr['interface'] = self._interface return attr @@ -733,7 +772,8 @@ def link_homematic(self): # Initialize self._homematic = self.hass.data[DATA_HOMEMATIC] - self._hmdevice = self._homematic.devices[self._proxy][self._address] + self._hmdevice = \ + self._homematic.devices[self._interface][self._address] self._connected = True try: @@ -767,6 +807,9 @@ def _hm_event_callback(self, device, caller, attribute, value): if attribute == 'UNREACH': self._available = bool(value) has_changed = True + elif not self.available: + self._available = False + has_changed = True # If it has changed data point, update HASS if has_changed: diff --git a/homeassistant/components/homematic/services.yaml b/homeassistant/components/homematic/services.yaml new file mode 100644 index 0000000000000..c2946b51842c2 --- /dev/null +++ b/homeassistant/components/homematic/services.yaml @@ -0,0 +1,68 @@ +# Describes the format for available component services + +virtualkey: + description: Press a virtual key from CCU/Homegear or simulate keypress. + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote. + example: BidCoS-RF + channel: + description: Channel for calling a keypress. + example: 1 + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT. + example: PRESS_LONG + interface: + description: (Optional) for set an interface value. + example: Interfaces name from config + +set_variable_value: + description: Set the name of a node. + fields: + entity_id: + description: Name(s) of homematic central to set value. + example: 'homematic.ccu2' + name: + description: Name of the variable to set. + example: 'testvariable' + value: + description: New value + example: 1 + +set_device_value: + description: Set a device property on RPC XML interface. + fields: + address: + description: Address of homematic device or BidCoS-RF for virtual remote + example: BidCoS-RF + channel: + description: Channel for calling a keypress + example: 1 + param: + description: Event to send i.e. PRESS_LONG, PRESS_SHORT + example: PRESS_LONG + interface: + description: (Optional) for set an interface value + example: Interfaces name from config + value: + description: New value + example: 1 + +reconnect: + description: Reconnect to all Homematic Hubs. + +set_install_mode: + description: Set a RPC XML interface into installation mode. + fields: + interface: + description: Select the given interface into install mode + example: Interfaces name from config + mode: + description: (Default 1) 1= Normal mode / 2= Remove exists old links + example: 1 + time: + description: (Default 60) Time in seconds to run in install mode + example: 1 + address: + description: (Optional) Address of homematic device or BidCoS-RF to learn + example: LEQ3948571 diff --git a/homeassistant/components/homematicip_cloud.py b/homeassistant/components/homematicip_cloud.py new file mode 100644 index 0000000000000..d85d867d8f864 --- /dev/null +++ b/homeassistant/components/homematicip_cloud.py @@ -0,0 +1,261 @@ +""" +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.2.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'homematicip_cloud' + +COMPONENTS = [ + 'sensor', + 'binary_sensor', + 'switch', + 'light' +] + +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 d8647dea0c3ab..17906157a6e79 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -4,37 +4,34 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/http/ """ -import asyncio -import json +from ipaddress import ip_network import logging +import os import ssl -from ipaddress import ip_network -import os -import voluptuous as vol from aiohttp import web -from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently +from aiohttp.web_exceptions import HTTPMovedPermanently +import voluptuous as vol +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, SERVER_PORT) import homeassistant.helpers.config_validation as cv import homeassistant.remote as rem import homeassistant.util as hass_util -from homeassistant.const import ( - SERVER_PORT, CONTENT_TYPE_JSON, ALLOWED_CORS_HEADERS, - EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -from homeassistant.core import is_callback from homeassistant.util.logging import HideSensitiveDataFilter -from .auth import auth_middleware -from .ban import ban_middleware -from .const import ( - KEY_USE_X_FORWARDED_FOR, KEY_TRUSTED_NETWORKS, - KEY_BANS_ENABLED, KEY_LOGIN_THRESHOLD, - KEY_DEVELOPMENT, KEY_AUTHENTICATED) +from .auth import setup_auth +from .ban import setup_bans +from .cors import setup_cors +from .real_ip import setup_real_ip from .static import ( - staticresource_middleware, CachingFileResponse, CachingStaticResource) -from .util import get_real_ip + CachingFileResponse, CachingStaticResource, staticresource_middleware) + +# Import as alias +from .const import KEY_AUTHENTICATED, KEY_REAL_IP # noqa +from .view import HomeAssistantView # noqa -REQUIREMENTS = ['aiohttp_cors==0.5.3'] +REQUIREMENTS = ['aiohttp_cors==0.7.0'] DOMAIN = 'http' @@ -42,7 +39,6 @@ CONF_SERVER_HOST = 'server_host' CONF_SERVER_PORT = 'server_port' CONF_BASE_URL = 'base_url' -CONF_DEVELOPMENT = 'development' CONF_SSL_CERTIFICATE = 'ssl_certificate' CONF_SSL_KEY = 'ssl_key' CONF_CORS_ORIGINS = 'cors_allowed_origins' @@ -76,23 +72,23 @@ DEFAULT_SERVER_HOST = '0.0.0.0' DEFAULT_DEVELOPMENT = '0' -DEFAULT_LOGIN_ATTEMPT_THRESHOLD = -1 +NO_LOGIN_ATTEMPT_THRESHOLD = -1 HTTP_SCHEMA = vol.Schema({ - vol.Optional(CONF_API_PASSWORD, default=None): cv.string, + vol.Optional(CONF_API_PASSWORD): cv.string, vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string, vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, vol.Optional(CONF_BASE_URL): cv.string, - vol.Optional(CONF_DEVELOPMENT, default=DEFAULT_DEVELOPMENT): cv.string, - vol.Optional(CONF_SSL_CERTIFICATE, default=None): cv.isfile, - vol.Optional(CONF_SSL_KEY, default=None): cv.isfile, + vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_KEY): cv.isfile, vol.Optional(CONF_CORS_ORIGINS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_USE_X_FORWARDED_FOR, default=False): cv.boolean, vol.Optional(CONF_TRUSTED_NETWORKS, default=[]): vol.All(cv.ensure_list, [ip_network]), vol.Optional(CONF_LOGIN_ATTEMPTS_THRESHOLD, - default=DEFAULT_LOGIN_ATTEMPT_THRESHOLD): cv.positive_int, + default=NO_LOGIN_ATTEMPT_THRESHOLD): + vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD), vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean }) @@ -101,20 +97,18 @@ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the HTTP API and debug interface.""" conf = config.get(DOMAIN) if conf is None: conf = HTTP_SCHEMA({}) - api_password = conf[CONF_API_PASSWORD] + api_password = conf.get(CONF_API_PASSWORD) server_host = conf[CONF_SERVER_HOST] server_port = conf[CONF_SERVER_PORT] - development = conf[CONF_DEVELOPMENT] == '1' - ssl_certificate = conf[CONF_SSL_CERTIFICATE] - ssl_key = conf[CONF_SSL_KEY] + ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) + ssl_key = conf.get(CONF_SSL_KEY) cors_origins = conf[CONF_CORS_ORIGINS] use_x_forwarded_for = conf[CONF_USE_X_FORWARDED_FOR] trusted_networks = conf[CONF_TRUSTED_NETWORKS] @@ -125,9 +119,8 @@ def async_setup(hass, config): logging.getLogger('aiohttp.access').addFilter( HideSensitiveDataFilter(api_password)) - server = HomeAssistantWSGI( + server = HomeAssistantHTTP( hass, - development=development, server_host=server_host, server_port=server_port, api_password=api_password, @@ -140,16 +133,14 @@ def async_setup(hass, config): is_ban_enabled=is_ban_enabled ) - @asyncio.coroutine - def stop_server(event): + async def stop_server(event): """Stop the server.""" - yield from server.stop() + await server.stop() - @asyncio.coroutine - def start_server(event): + async def start_server(event): """Start the server.""" hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) - yield from server.start() + await server.start() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_server) @@ -172,49 +163,40 @@ def start_server(event): return True -class HomeAssistantWSGI(object): - """WSGI server for Home Assistant.""" +class HomeAssistantHTTP(object): + """HTTP server for Home Assistant.""" - def __init__(self, hass, development, api_password, ssl_certificate, + def __init__(self, hass, api_password, ssl_certificate, ssl_key, server_host, server_port, cors_origins, use_x_forwarded_for, trusted_networks, login_threshold, is_ban_enabled): - """Initialize the WSGI Home Assistant server.""" - import aiohttp_cors + """Initialize the HTTP Home Assistant server.""" + app = self.app = web.Application( + middlewares=[staticresource_middleware]) - middlewares = [auth_middleware, staticresource_middleware] + # This order matters + setup_real_ip(app, use_x_forwarded_for) if is_ban_enabled: - middlewares.insert(0, ban_middleware) + setup_bans(hass, app, login_threshold) + + setup_auth(app, trusted_networks, api_password) + + if cors_origins: + setup_cors(app, cors_origins) - self.app = web.Application(middlewares=middlewares) - self.app['hass'] = hass - self.app[KEY_USE_X_FORWARDED_FOR] = use_x_forwarded_for - self.app[KEY_TRUSTED_NETWORKS] = trusted_networks - self.app[KEY_BANS_ENABLED] = is_ban_enabled - self.app[KEY_LOGIN_THRESHOLD] = login_threshold - self.app[KEY_DEVELOPMENT] = development + app['hass'] = hass self.hass = hass - self.development = development self.api_password = api_password self.ssl_certificate = ssl_certificate self.ssl_key = ssl_key self.server_host = server_host self.server_port = server_port + self.is_ban_enabled = is_ban_enabled self._handler = None self.server = None - if cors_origins: - self.cors = aiohttp_cors.setup(self.app, defaults={ - host: aiohttp_cors.ResourceOptions( - allow_headers=ALLOWED_CORS_HEADERS, - allow_methods='*', - ) for host in cors_origins - }) - else: - self.cors = None - def register_view(self, view): """Register a view with the WSGI server. @@ -262,18 +244,15 @@ def register_static_path(self, url_path, path, cache_headers=True): resource = CachingStaticResource else: resource = web.StaticResource - self.app.router.register_resource(resource(url_path, path)) return if cache_headers: - @asyncio.coroutine - def serve_file(request): + async def serve_file(request): """Serve file from disk.""" return CachingFileResponse(path) else: - @asyncio.coroutine - def serve_file(request): + async def serve_file(request): """Serve file from disk.""" return web.FileResponse(path) @@ -291,18 +270,13 @@ def serve_file(request): self.app.router.add_route('GET', url_pattern, serve_file) - @asyncio.coroutine - def start(self): + async def start(self): """Start the WSGI server.""" - cors_added = set() - if self.cors is not None: - for route in list(self.app.router.routes()): - if hasattr(route, 'resource'): - route = route.resource - if route in cors_added: - continue - self.cors.add(route) - cors_added.add(route) + # We misunderstood the startup signal. You're not allowed to change + # anything during startup. Temp workaround. + # pylint: disable=protected-access + self.app._on_startup.freeze() + await self.app.startup() if self.ssl_certificate: try: @@ -321,125 +295,24 @@ def start(self): # Aiohttp freezes apps after start so that no changes can be made. # However in Home Assistant components can be discovered after boot. # This will now raise a RunTimeError. - # To work around this we now fake that we are frozen. - # A more appropriate fix would be to create a new app and - # re-register all redirects, views, static paths. - self.app._frozen = True # pylint: disable=protected-access + # To work around this we now prevent the router from getting frozen + self.app._router.freeze = lambda: None self._handler = self.app.make_handler(loop=self.hass.loop) try: - self.server = yield from self.hass.loop.create_server( + self.server = await self.hass.loop.create_server( self._handler, self.server_host, self.server_port, ssl=context) except OSError as error: _LOGGER.error("Failed to create HTTP server at port %d: %s", self.server_port, error) - self.app._frozen = False # pylint: disable=protected-access - - @asyncio.coroutine - def stop(self): + async def stop(self): """Stop the WSGI server.""" if self.server: self.server.close() - yield from self.server.wait_closed() - yield from self.app.shutdown() + await self.server.wait_closed() + await self.app.shutdown() if self._handler: - yield from self._handler.finish_connections(60.0) - yield from self.app.cleanup() - - -class HomeAssistantView(object): - """Base view for all views.""" - - url = None - extra_urls = [] - requires_auth = True # Views inheriting from this class can override this - - # pylint: disable=no-self-use - def json(self, result, status_code=200): - """Return a JSON response.""" - msg = json.dumps( - result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8') - return web.Response( - body=msg, content_type=CONTENT_TYPE_JSON, status=status_code) - - def json_message(self, error, status_code=200): - """Return a JSON message response.""" - return self.json({'message': error}, status_code) - - @asyncio.coroutine - # pylint: disable=no-self-use - 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' - urls = [self.url] + self.extra_urls - - for method in ('get', 'post', 'delete', 'put'): - handler = getattr(self, method, None) - - if not handler: - continue - - handler = request_handler_factory(self, handler) - - for url in urls: - router.add_route(method, url, handler) - - # aiohttp_cors does not work with class based views - # self.app.router.add_route('*', self.url, self, name=self.name) - - # for url in self.extra_urls: - # self.app.router.add_route('*', url, self) - - -def request_handler_factory(view, handler): - """Wrap the handler classes.""" - assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \ - "Handler should be a coroutine or a callback." - - @asyncio.coroutine - def handle(request): - """Handle incoming request.""" - if not request.app['hass'].is_running: - return web.Response(status=503) - - remote_addr = get_real_ip(request) - authenticated = request.get(KEY_AUTHENTICATED, False) - - if view.requires_auth and not authenticated: - raise HTTPUnauthorized() - - _LOGGER.info('Serving %s to %s (auth: %s)', - request.path, remote_addr, authenticated) - - result = handler(request, **request.match_info) - - if asyncio.iscoroutine(result): - result = yield from result - - if isinstance(result, web.StreamResponse): - # The method handler returned a ready-made Response, how nice of it - return result - - status_code = 200 - - if isinstance(result, tuple): - result, status_code = result - - if isinstance(result, str): - result = result.encode('utf-8') - elif result is None: - result = b'' - elif not isinstance(result, bytes): - assert False, ('Result should be None, string, bytes or Response. ' - 'Got: {}').format(result) - - return web.Response(body=result, status=status_code) - - return handle + await self._handler.shutdown(10) + await self.app.cleanup() diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index a00da9ee5b6d6..c4723abccee34 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -1,66 +1,114 @@ """Authentication for HTTP component.""" -import asyncio + +import base64 import hmac import logging +from aiohttp import hdrs +from aiohttp.web import middleware + +from homeassistant.core import callback from homeassistant.const import HTTP_HEADER_HA_AUTH -from .util import get_real_ip -from .const import KEY_TRUSTED_NETWORKS, KEY_AUTHENTICATED +from .const import KEY_AUTHENTICATED, KEY_REAL_IP DATA_API_PASSWORD = 'api_password' _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def auth_middleware(app, handler): - """Authenticate as middleware.""" - # If no password set, just always set authenticated=True - if app['hass'].http.api_password is None: - @asyncio.coroutine - def no_auth_middleware_handler(request): - """Auth middleware to approve all requests.""" +@callback +def setup_auth(app, trusted_networks, api_password): + """Create auth middleware for the app.""" + @middleware + async def auth_middleware(request, handler): + """Authenticate as middleware.""" + # If no password set, just always set authenticated=True + if api_password is None: request[KEY_AUTHENTICATED] = True - return handler(request) - - return no_auth_middleware_handler + return await handler(request) - @asyncio.coroutine - def auth_middleware_handler(request): - """Auth middleware to check authentication.""" - # Auth code verbose on purpose + # Check authentication authenticated = False if (HTTP_HEADER_HA_AUTH in request.headers and - validate_password( - request, request.headers[HTTP_HEADER_HA_AUTH])): + hmac.compare_digest( + 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 - validate_password(request, 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 + await async_validate_auth_header(api_password, request)): authenticated = True - elif is_trusted_ip(request): + elif _is_trusted_ip(request, trusted_networks): authenticated = True request[KEY_AUTHENTICATED] = authenticated + return await handler(request) - return handler(request) + async def auth_startup(app): + """Initialize auth middleware when app starts up.""" + app.middlewares.append(auth_middleware) - return auth_middleware_handler + app.on_startup.append(auth_startup) -def is_trusted_ip(request): +def _is_trusted_ip(request, trusted_networks): """Test if request is from a trusted ip.""" - ip_addr = get_real_ip(request) + ip_addr = request[KEY_REAL_IP] - return ip_addr and any( + return any( ip_addr in trusted_network for trusted_network - in request.app[KEY_TRUSTED_NETWORKS]) + in 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')) + + +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 + + try: + auth_type, auth_val = \ + request.headers.get(hdrs.AUTHORIZATION).split(' ', 1) + except ValueError: + # If no space in authorization header + return False + + 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': + return False + + return hmac.compare_digest(api_password.encode('utf-8'), + password.encode('utf-8')) + + if auth_type != 'Bearer': + return False + + 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/ban.py b/homeassistant/components/http/ban.py index aa01ccde8d763..fe8b7db84d1b9 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -1,26 +1,29 @@ """Ban logic for HTTP component.""" -import asyncio + from collections import defaultdict from datetime import datetime from ipaddress import ip_address import logging import os +from aiohttp.web import middleware from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol +from homeassistant.core import callback from homeassistant.components import persistent_notification from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.util.yaml import dump -from .const import ( - KEY_BANS_ENABLED, KEY_BANNED_IPS, KEY_LOGIN_THRESHOLD, - KEY_FAILED_LOGIN_ATTEMPTS) -from .util import get_real_ip +from .const import KEY_REAL_IP _LOGGER = logging.getLogger(__name__) +KEY_BANNED_IPS = 'ha_banned_ips' +KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts' +KEY_LOGIN_THRESHOLD = 'ha_login_threshold' + NOTIFICATION_ID_BAN = 'ip-ban' NOTIFICATION_ID_LOGIN = 'http-login' @@ -32,41 +35,45 @@ }) -@asyncio.coroutine -def ban_middleware(app, handler): - """IP Ban middleware.""" - if not app[KEY_BANS_ENABLED]: - return handler - - if KEY_BANNED_IPS not in app: - hass = app['hass'] - app[KEY_BANNED_IPS] = yield from hass.async_add_job( +@callback +def setup_bans(hass, app, login_threshold): + """Create IP Ban middleware for the app.""" + async def ban_startup(app): + """Initialize bans when app starts up.""" + app.middlewares.append(ban_middleware) + app[KEY_BANNED_IPS] = await hass.async_add_job( load_ip_bans_config, hass.config.path(IP_BANS_FILE)) + app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) + app[KEY_LOGIN_THRESHOLD] = login_threshold - @asyncio.coroutine - def ban_middleware_handler(request): - """Verify if IP is not banned.""" - ip_address_ = get_real_ip(request) + app.on_startup.append(ban_startup) - is_banned = any(ip_ban.ip_address == ip_address_ - for ip_ban in request.app[KEY_BANNED_IPS]) - if is_banned: - raise HTTPForbidden() +@middleware +async def ban_middleware(request, handler): + """IP Ban middleware.""" + if KEY_BANNED_IPS not in request.app: + _LOGGER.error('IP Ban middleware loaded but banned IPs not loaded') + return await handler(request) - try: - return (yield from handler(request)) - except HTTPUnauthorized: - yield from process_wrong_login(request) - raise + # Verify if IP is not banned + ip_address_ = request[KEY_REAL_IP] + is_banned = any(ip_ban.ip_address == ip_address_ + for ip_ban in request.app[KEY_BANNED_IPS]) + + if is_banned: + raise HTTPForbidden() - return ban_middleware_handler + try: + return await handler(request) + except HTTPUnauthorized: + await process_wrong_login(request) + raise -@asyncio.coroutine -def process_wrong_login(request): +async def process_wrong_login(request): """Process a wrong login attempt.""" - remote_addr = get_real_ip(request) + remote_addr = request[KEY_REAL_IP] msg = ('Login attempt or request with invalid authentication ' 'from {}'.format(remote_addr)) @@ -75,13 +82,11 @@ def process_wrong_login(request): request.app['hass'], msg, 'Login attempt failed', NOTIFICATION_ID_LOGIN) - if (not request.app[KEY_BANS_ENABLED] or + # Check if ban middleware is loaded + if (KEY_BANNED_IPS not in request.app or request.app[KEY_LOGIN_THRESHOLD] < 1): return - if KEY_FAILED_LOGIN_ATTEMPTS not in request.app: - request.app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) - request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] += 1 if (request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > @@ -90,7 +95,7 @@ def process_wrong_login(request): request.app[KEY_BANNED_IPS].append(new_ban) hass = request.app['hass'] - yield from hass.async_add_job( + await hass.async_add_job( update_ip_bans_config, hass.config.path(IP_BANS_FILE), new_ban) _LOGGER.warning( @@ -105,7 +110,7 @@ def process_wrong_login(request): class IpBan(object): """Represents banned IP address.""" - def __init__(self, ip_ban: str, banned_at: datetime=None) -> None: + def __init__(self, ip_ban: str, banned_at: datetime = None) -> None: """Initialize IP Ban object.""" self.ip_address = ip_address(ip_ban) self.banned_at = banned_at or datetime.utcnow() diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index 5922042e4fb57..e5494e945c43e 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,12 +1,3 @@ """HTTP specific constants.""" KEY_AUTHENTICATED = 'ha_authenticated' -KEY_USE_X_FORWARDED_FOR = 'ha_use_x_forwarded_for' -KEY_TRUSTED_NETWORKS = 'ha_trusted_networks' KEY_REAL_IP = 'ha_real_ip' -KEY_BANS_ENABLED = 'ha_bans_enabled' -KEY_BANNED_IPS = 'ha_banned_ips' -KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts' -KEY_LOGIN_THRESHOLD = 'ha_login_threshold' -KEY_DEVELOPMENT = 'ha_development' - -HTTP_HEADER_X_FORWARDED_FOR = 'X-Forwarded-For' diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py new file mode 100644 index 0000000000000..0a37f22867efc --- /dev/null +++ b/homeassistant/components/http/cors.py @@ -0,0 +1,42 @@ +"""Provide cors support for the HTTP component.""" + + +from aiohttp.hdrs import ACCEPT, ORIGIN, CONTENT_TYPE + +from homeassistant.const import ( + HTTP_HEADER_X_REQUESTED_WITH, HTTP_HEADER_HA_AUTH) + + +from homeassistant.core import callback + + +ALLOWED_CORS_HEADERS = [ + ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, + HTTP_HEADER_HA_AUTH] + + +@callback +def setup_cors(app, origins): + """Setup cors.""" + import aiohttp_cors + + cors = aiohttp_cors.setup(app, defaults={ + host: aiohttp_cors.ResourceOptions( + allow_headers=ALLOWED_CORS_HEADERS, + allow_methods='*', + ) for host in origins + }) + + async def cors_startup(app): + """Initialize cors when app starts up.""" + cors_added = set() + + for route in list(app.router.routes()): + if hasattr(route, 'resource'): + route = route.resource + if route in cors_added: + continue + cors.add(route) + cors_added.add(route) + + app.on_startup.append(cors_startup) diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py new file mode 100644 index 0000000000000..8fc7cd8e658db --- /dev/null +++ b/homeassistant/components/http/data_validator.py @@ -0,0 +1,50 @@ +"""Decorator for view methods to help with data validation.""" + +from functools import wraps +import logging + +import voluptuous as vol + +_LOGGER = logging.getLogger(__name__) + + +class RequestDataValidator: + """Decorator that will validate the incoming data. + + Takes in a voluptuous schema and adds 'post_data' as + keyword argument to the function call. + + Will return a 400 if no JSON provided or doesn't match schema. + """ + + def __init__(self, schema, allow_empty=False): + """Initialize the decorator.""" + self._schema = schema + self._allow_empty = allow_empty + + def __call__(self, method): + """Decorate a function.""" + @wraps(method) + async def wrapper(view, request, *args, **kwargs): + """Wrap a request handler with data validation.""" + data = None + try: + data = await request.json() + except ValueError: + if not self._allow_empty or \ + (await request.content.read()) != b'': + _LOGGER.error('Invalid JSON received.') + return view.json_message('Invalid JSON.', 400) + data = {} + + try: + kwargs['data'] = self._schema(data) + except vol.Invalid as err: + _LOGGER.error('Data does not match schema: %s', err) + return view.json_message( + 'Message format incorrect: {}'.format(err), 400) + + result = await method(view, request, *args, **kwargs) + return result + + return wrapper diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py new file mode 100644 index 0000000000000..c394016a683c4 --- /dev/null +++ b/homeassistant/components/http/real_ip.py @@ -0,0 +1,33 @@ +"""Middleware to fetch real IP.""" + +from ipaddress import ip_address + +from aiohttp.web import middleware +from aiohttp.hdrs import X_FORWARDED_FOR + +from homeassistant.core import callback + +from .const import KEY_REAL_IP + + +@callback +def setup_real_ip(app, use_x_forwarded_for): + """Create IP Ban middleware for the app.""" + @middleware + async def real_ip_middleware(request, handler): + """Real IP middleware.""" + if (use_x_forwarded_for and + X_FORWARDED_FOR in request.headers): + request[KEY_REAL_IP] = ip_address( + request.headers.get(X_FORWARDED_FOR).split(',')[0]) + else: + request[KEY_REAL_IP] = \ + ip_address(request.transport.get_extra_info('peername')[0]) + + return await handler(request) + + async def app_startup(app): + """Initialize bans when app starts up.""" + app.middlewares.append(real_ip_middleware) + + app.on_startup.append(app_startup) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 21e955fc9686e..3fbaf703d0679 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -1,14 +1,12 @@ """Static file handling for HTTP component.""" -import asyncio + import re from aiohttp import hdrs -from aiohttp.web import FileResponse +from aiohttp.web import FileResponse, middleware from aiohttp.web_exceptions import HTTPNotFound from aiohttp.web_urldispatcher import StaticResource -from yarl import unquote - -from .const import KEY_DEVELOPMENT +from yarl import URL _FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE) @@ -16,9 +14,8 @@ class CachingStaticResource(StaticResource): """Static Resource handler that will add cache headers.""" - @asyncio.coroutine - def _handle(self, request): - filename = unquote(request.match_info['filename']) + async def _handle(self, request): + filename = URL(request.match_info['filename']).path try: # PyLint is wrong about resolve not being a member. # pylint: disable=no-member @@ -34,13 +31,14 @@ def _handle(self, request): raise HTTPNotFound() from error if filepath.is_dir(): - return (yield from super()._handle(request)) + return await super()._handle(request) elif filepath.is_file(): return CachingFileResponse(filepath, chunk_size=self._chunk_size) else: raise HTTPNotFound +# pylint: disable=too-many-ancestors class CachingFileResponse(FileResponse): """FileSender class that caches output if not in dev mode.""" @@ -50,35 +48,29 @@ def __init__(self, *args, **kwargs): orig_sendfile = self._sendfile - @asyncio.coroutine - def sendfile(request, fobj, count): + async def sendfile(request, fobj, count): """Sendfile that includes a cache header.""" - if not request.app[KEY_DEVELOPMENT]: - cache_time = 31 * 86400 # = 1 month - self.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format( - cache_time) + cache_time = 31 * 86400 # = 1 month + self.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format( + cache_time) - yield from orig_sendfile(request, fobj, count) + await orig_sendfile(request, fobj, count) # Overwriting like this because __init__ can change implementation. self._sendfile = sendfile -@asyncio.coroutine -def staticresource_middleware(app, handler): +@middleware +async def staticresource_middleware(request, handler): """Middleware to strip out fingerprint from fingerprinted assets.""" - @asyncio.coroutine - def static_middleware_handler(request): - """Strip out fingerprints from resource names.""" - if not request.path.startswith('/static/'): - return handler(request) - - fingerprinted = _FINGERPRINT.match(request.match_info['filename']) + path = request.path + if not path.startswith('/static/') and not path.startswith('/frontend'): + return await handler(request) - if fingerprinted: - request.match_info['filename'] = \ - '{}.{}'.format(*fingerprinted.groups()) + fingerprinted = _FINGERPRINT.match(request.match_info['filename']) - return handler(request) + if fingerprinted: + request.match_info['filename'] = \ + '{}.{}'.format(*fingerprinted.groups()) - return static_middleware_handler + return await handler(request) diff --git a/homeassistant/components/http/util.py b/homeassistant/components/http/util.py deleted file mode 100644 index 1a5a3d98a227f..0000000000000 --- a/homeassistant/components/http/util.py +++ /dev/null @@ -1,25 +0,0 @@ -"""HTTP utilities.""" -from ipaddress import ip_address - -from .const import ( - KEY_REAL_IP, KEY_USE_X_FORWARDED_FOR, HTTP_HEADER_X_FORWARDED_FOR) - - -def get_real_ip(request): - """Get IP address of client.""" - if KEY_REAL_IP in request: - return request[KEY_REAL_IP] - - if (request.app[KEY_USE_X_FORWARDED_FOR] and - HTTP_HEADER_X_FORWARDED_FOR in request.headers): - request[KEY_REAL_IP] = ip_address( - request.headers.get(HTTP_HEADER_X_FORWARDED_FOR).split(',')[0]) - else: - peername = request.transport.get_extra_info('peername') - - if peername: - request[KEY_REAL_IP] = ip_address(peername[0]) - else: - request[KEY_REAL_IP] = None - - return request[KEY_REAL_IP] diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py new file mode 100644 index 0000000000000..3de276564ebb8 --- /dev/null +++ b/homeassistant/components/http/view.py @@ -0,0 +1,119 @@ +""" +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/ +""" +import asyncio +import json +import logging + +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized, HTTPInternalServerError + +import homeassistant.remote as rem +from homeassistant.core import is_callback +from homeassistant.const import CONTENT_TYPE_JSON + +from .const import KEY_AUTHENTICATED, KEY_REAL_IP + + +_LOGGER = logging.getLogger(__name__) + + +class HomeAssistantView(object): + """Base view for all views.""" + + url = None + extra_urls = [] + requires_auth = True # Views inheriting from this class can override this + + # pylint: disable=no-self-use + def json(self, result, status_code=200, headers=None): + """Return a JSON response.""" + try: + msg = json.dumps( + result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8') + except TypeError as err: + _LOGGER.error('Unable to serialize to JSON: %s\n%s', err, result) + raise HTTPInternalServerError + response = web.Response( + body=msg, content_type=CONTENT_TYPE_JSON, status=status_code, + headers=headers) + response.enable_compression() + return response + + def json_message(self, message, status_code=200, message_code=None, + headers=None): + """Return a JSON message response.""" + data = {'message': message} + if message_code is not None: + data['code'] = message_code + return self.json(data, status_code, headers=headers) + + def register(self, router): + """Register the view with a router.""" + assert self.url is not None, 'No url set for view' + urls = [self.url] + self.extra_urls + + for method in ('get', 'post', 'delete', 'put'): + handler = getattr(self, method, None) + + if not handler: + continue + + handler = request_handler_factory(self, handler) + + for url in urls: + router.add_route(method, url, handler) + + # aiohttp_cors does not work with class based views + # self.app.router.add_route('*', self.url, self, name=self.name) + + # for url in self.extra_urls: + # self.app.router.add_route('*', url, self) + + +def request_handler_factory(view, handler): + """Wrap the handler classes.""" + assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \ + "Handler should be a coroutine or a callback." + + async def handle(request): + """Handle incoming request.""" + if not request.app['hass'].is_running: + return web.Response(status=503) + + authenticated = request.get(KEY_AUTHENTICATED, False) + + if view.requires_auth and not authenticated: + raise HTTPUnauthorized() + + _LOGGER.info('Serving %s to %s (auth: %s)', + request.path, request.get(KEY_REAL_IP), authenticated) + + result = handler(request, **request.match_info) + + if asyncio.iscoroutine(result): + result = await result + + if isinstance(result, web.StreamResponse): + # The method handler returned a ready-made Response, how nice of it + return result + + status_code = 200 + + if isinstance(result, tuple): + result, status_code = result + + if isinstance(result, str): + result = result.encode('utf-8') + elif result is None: + result = b'' + elif not isinstance(result, bytes): + assert False, ('Result should be None, string, bytes or Response. ' + 'Got: {}').format(result) + + return web.Response(body=result, status=status_code) + + return handle diff --git a/homeassistant/components/hue/.translations/bg.json b/homeassistant/components/hue/.translations/bg.json new file mode 100644 index 0000000000000..276f5053bf7b7 --- /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 0000000000000..f5476f73edbbf --- /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 0000000000000..3e5e2b1d3d788 --- /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 0000000000000..d466488e9fcd8 --- /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 new file mode 100644 index 0000000000000..b0459ec39163a --- /dev/null +++ b/homeassistant/components/hue/.translations/en.json @@ -0,0 +1,29 @@ +{ + "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", + "unknown": "Unknown error occurred" + }, + "error": { + "linking": "Unknown linking error occurred.", + "register_failed": "Failed to register, please try again" + }, + "step": { + "init": { + "data": { + "host": "Host" + }, + "title": "Pick Hue bridge" + }, + "link": { + "description": "Press the button on the bridge to register Philips Hue with Home Assistant.\n\n![Location of button on 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/es.json b/homeassistant/components/hue/.translations/es.json new file mode 100644 index 0000000000000..d58469af044fd --- /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 0000000000000..a4032dcbcfc21 --- /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 0000000000000..2c7a8c1924d6d --- /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 0000000000000..47306a35414dc --- /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 0000000000000..c4ad10da2785b --- /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 0000000000000..88c611b163361 --- /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 0000000000000..309e9f6a29992 --- /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 0000000000000..784fa0d99a6d1 --- /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 0000000000000..8c4c45f9c897c --- /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 0000000000000..91541edcc7d7f --- /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 0000000000000..ea1e4fff1bf91 --- /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 0000000000000..4245ce02c66e1 --- /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 0000000000000..1d904070b8146 --- /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 0000000000000..eae4c09da497d --- /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 new file mode 100644 index 0000000000000..0aed854d4e499 --- /dev/null +++ b/homeassistant/components/hue/__init__.py @@ -0,0 +1,139 @@ +""" +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 ipaddress +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_FILENAME, CONF_HOST +from homeassistant.helpers import aiohttp_client, config_validation as cv + +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.3.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_BRIDGES = "bridges" + +CONF_ALLOW_UNREACHABLE = 'allow_unreachable' +DEFAULT_ALLOW_UNREACHABLE = False + +PHUE_CONFIG_FILE = 'phue.conf' + +CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" +DEFAULT_ALLOW_HUE_GROUPS = True + +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_HUE_GROUPS, + default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_BRIDGES): + vol.All(cv.ensure_list, [BRIDGE_CONFIG_SCHEMA]), + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Hue platform.""" + conf = config.get(DOMAIN) + if conf is None: + conf = {} + + 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 + 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 = [] + + if not bridges: + return True + + for bridge_conf in bridges: + host = bridge_conf[CONF_HOST] + + # Store config in hass.data so the config entry can find it + hass.data[DOMAIN][host] = bridge_conf + + # If configured, the bridge will be set up during config entry phase + if host in configured: + continue + + # 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 + + +async def async_setup_entry(hass, entry): + """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 0000000000000..5ff5e2dbf6f86 --- /dev/null +++ b/homeassistant/components/hue/bridge.py @@ -0,0 +1,175 @@ +"""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) + + scene_id = next( + (scene.id for scene in self.api.scenes.values() + if scene.name == scene_name), None) + + # 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 0000000000000..af67a594495e5 --- /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 0000000000000..2eb30d478044e --- /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 0000000000000..dd217c3bc263a --- /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 new file mode 100644 index 0000000000000..fc9e91c93d752 --- /dev/null +++ b/homeassistant/components/hue/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "title": "Philips Hue Bridge", + "step": { + "init": { + "title": "Pick Hue bridge", + "data": { + "host": "Host" + } + }, + "link": { + "title": "Link Hub", + "description": "Press the button on the bridge to register Philips Hue with Home Assistant.\n\n![Location of button on bridge](/static/images/config_philips_hue.jpg)" + } + }, + "error": { + "register_failed": "Failed to register, please try again", + "linking": "Unknown linking error occurred." + }, + "abort": { + "discover_timeout": "Unable to discover Hue bridges", + "no_bridges": "No Philips Hue bridges discovered", + "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/ihc/__init__.py b/homeassistant/components/ihc/__init__.py new file mode 100644 index 0000000000000..0c0100bc9f595 --- /dev/null +++ b/homeassistant/components/ihc/__init__.py @@ -0,0 +1,214 @@ +""" +Support for IHC devices. + +For more details about this component, please refer to the documentation at +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_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_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 + +REQUIREMENTS = ['ihcsdk==2.2.0'] + +DOMAIN = 'ihc' +IHC_DATA = 'ihc' +IHC_CONTROLLER = 'controller' +IHC_INFO = 'info' +AUTO_SETUP_YAML = 'ihc_auto_setup.yaml' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_URL): cv.string, + 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, + }), +}, extra=vol.ALLOW_EXTRA) + +AUTO_SETUP_SCHEMA = vol.Schema({ + vol.Optional(CONF_BINARY_SENSOR, default=[]): + vol.All(cv.ensure_list, [ + vol.All({ + vol.Required(CONF_XPATH): cv.string, + vol.Required(CONF_NODE): cv.string, + vol.Optional(CONF_TYPE): cv.string, + vol.Optional(CONF_INVERTING, default=False): cv.boolean, + }) + ]), + vol.Optional(CONF_LIGHT, default=[]): + vol.All(cv.ensure_list, [ + vol.All({ + vol.Required(CONF_XPATH): cv.string, + vol.Required(CONF_NODE): cv.string, + vol.Optional(CONF_DIMMABLE, default=False): cv.boolean, + }) + ]), + vol.Optional(CONF_SENSOR, default=[]): + vol.All(cv.ensure_list, [ + vol.All({ + vol.Required(CONF_XPATH): cv.string, + vol.Required(CONF_NODE): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT, + default=TEMP_CELSIUS): cv.string, + }) + ]), + vol.Optional(CONF_SWITCH, default=[]): + vol.All(cv.ensure_list, [ + vol.All({ + vol.Required(CONF_XPATH): cv.string, + vol.Required(CONF_NODE): cv.string, + }) + ]), +}) + +SET_RUNTIME_VALUE_BOOL_SCHEMA = vol.Schema({ + vol.Required(ATTR_IHC_ID): cv.positive_int, + vol.Required(ATTR_VALUE): cv.boolean +}) + +SET_RUNTIME_VALUE_INT_SCHEMA = vol.Schema({ + vol.Required(ATTR_IHC_ID): cv.positive_int, + vol.Required(ATTR_VALUE): int +}) + +SET_RUNTIME_VALUE_FLOAT_SCHEMA = vol.Schema({ + vol.Required(ATTR_IHC_ID): cv.positive_int, + vol.Required(ATTR_VALUE): vol.Coerce(float) +}) + +_LOGGER = logging.getLogger(__name__) + +IHC_PLATFORMS = ('binary_sensor', 'light', 'sensor', 'switch') + + +def setup(hass, config): + """Set up the IHC component.""" + from ihcsdk.ihccontroller import IHCController + conf = config[DOMAIN] + url = conf[CONF_URL] + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + ihc_controller = IHCController(url, username, password) + + if not ihc_controller.authenticate(): + _LOGGER.error("Unable to authenticate on IHC controller") + return False + + if (conf[CONF_AUTOSETUP] and + not autosetup_ihc_products(hass, config, ihc_controller)): + return False + + hass.data[IHC_DATA] = { + IHC_CONTROLLER: ihc_controller, + IHC_INFO: conf[CONF_INFO]} + + setup_service_functions(hass, ihc_controller) + return True + + +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 ICH controller") + return False + project = xml.etree.ElementTree.fromstring(project_xml) + + # if an auto setup file exist in the configuration it will override + yaml_path = hass.config.path(AUTO_SETUP_YAML) + if not os.path.isfile(yaml_path): + yaml_path = os.path.join(os.path.dirname(__file__), AUTO_SETUP_YAML) + yaml = load_yaml_config_file(yaml_path) + try: + auto_setup_conf = AUTO_SETUP_SCHEMA(yaml) + except vol.Invalid as exception: + _LOGGER.error("Invalid IHC auto setup data: %s", exception) + return False + groups = project.findall('.//group') + for component in IHC_PLATFORMS: + component_setup = auto_setup_conf[component] + discovery_info = get_discovery_info(component_setup, groups) + if discovery_info: + discovery.load_platform(hass, component, DOMAIN, discovery_info, + config) + return True + + +def get_discovery_info(component_setup, groups): + """Get discovery info for specified IHC component.""" + discovery_data = {} + for group in groups: + groupname = group.attrib['name'] + for product_cfg in component_setup: + products = group.findall(product_cfg[CONF_XPATH]) + for product in products: + nodes = product.findall(product_cfg[CONF_NODE]) + for node in nodes: + if ('setting' in node.attrib + and node.attrib['setting'] == 'yes'): + continue + ihc_id = int(node.attrib['id'].strip('_'), 0) + name = '{}_{}'.format(groupname, ihc_id) + device = { + 'ihc_id': ihc_id, + 'product': product, + 'product_cfg': product_cfg} + discovery_data[name] = device + return discovery_data + + +def setup_service_functions(hass: HomeAssistantType, ihc_controller): + """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] + value = call.data[ATTR_VALUE] + ihc_controller.set_runtime_value_bool(ihc_id, value) + + def set_runtime_value_int(call): + """Set a IHC runtime integer value service function.""" + ihc_id = call.data[ATTR_IHC_ID] + value = call.data[ATTR_VALUE] + ihc_controller.set_runtime_value_int(ihc_id, value) + + def set_runtime_value_float(call): + """Set a IHC runtime float value service function.""" + ihc_id = call.data[ATTR_IHC_ID] + value = call.data[ATTR_VALUE] + ihc_controller.set_runtime_value_float(ihc_id, value) + + hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_BOOL, + set_runtime_value_bool, + schema=SET_RUNTIME_VALUE_BOOL_SCHEMA) + hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_INT, + set_runtime_value_int, + schema=SET_RUNTIME_VALUE_INT_SCHEMA) + hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_FLOAT, + set_runtime_value_float, + schema=SET_RUNTIME_VALUE_FLOAT_SCHEMA) + + +def validate_name(config): + """Validate device name.""" + if CONF_NAME in config: + return config + ihcid = config[CONF_ID] + name = 'ihc_{}'.format(ihcid) + config[CONF_NAME] = name + return config diff --git a/homeassistant/components/ihc/const.py b/homeassistant/components/ihc/const.py new file mode 100644 index 0000000000000..b06746c8e7aef --- /dev/null +++ b/homeassistant/components/ihc/const.py @@ -0,0 +1,19 @@ +"""IHC component constants.""" + +CONF_AUTOSETUP = 'auto_setup' +CONF_INFO = 'info' +CONF_XPATH = 'xpath' +CONF_NODE = 'node' +CONF_INVERTING = 'inverting' +CONF_DIMMABLE = 'dimmable' +CONF_BINARY_SENSOR = 'binary_sensor' +CONF_LIGHT = 'light' +CONF_SENSOR = 'sensor' +CONF_SWITCH = 'switch' + +ATTR_IHC_ID = 'ihc_id' +ATTR_VALUE = 'value' + +SERVICE_SET_RUNTIME_VALUE_BOOL = "set_runtime_value_bool" +SERVICE_SET_RUNTIME_VALUE_INT = "set_runtime_value_int" +SERVICE_SET_RUNTIME_VALUE_FLOAT = "set_runtime_value_float" diff --git a/homeassistant/components/ihc/ihc_auto_setup.yaml b/homeassistant/components/ihc/ihc_auto_setup.yaml new file mode 100644 index 0000000000000..81d5bf37977df --- /dev/null +++ b/homeassistant/components/ihc/ihc_auto_setup.yaml @@ -0,0 +1,98 @@ +# IHC auto setup configuration. +# To customize this, copy this file to the home assistant configuration +# folder and make your changes. + +binary_sensor: + # Magnet contact + - xpath: './/product_dataline[@product_identifier="_0x2109"]' + node: 'dataline_input' + type: 'opening' + inverting: True + # Pir sensors + - xpath: './/product_dataline[@product_identifier="_0x210e"]' + node: 'dataline_input[1]' + type: 'motion' + # Pir sensors twilight sensor + - xpath: './/product_dataline[@product_identifier="_0x0"]' + node: 'dataline_input[1]' + type: 'motion' + # Pir sensors alarm + - xpath: './/product_dataline[@product_identifier="_0x210f"]' + node: 'dataline_input' + type: 'motion' + # Smoke detector + - xpath: './/product_dataline[@product_identifier="_0x210a"]' + node: 'dataline_input' + type: 'smoke' + # leak detector + - xpath: './/product_dataline[@product_identifier="_0x210c"]' + node: 'dataline_input' + type: 'moisture' + # light detector + - xpath: './/product_dataline[@product_identifier="_0x2110"]' + node: 'dataline_input' + type: 'light' + +light: + # Wireless Combi dimmer 4 buttons + - xpath: './/product_airlink[@product_identifier="_0x4406"]' + node: 'airlink_dimming' + dimmable: True + # Wireless Lamp outlet dimmer + - xpath: './/product_airlink[@product_identifier="_0x4304"]' + node: 'airlink_dimming' + dimmable: True + # Wireless universal dimmer + - xpath: './/product_airlink[@product_identifier="_0x4306"]' + node: 'airlink_dimming' + dimmable: True + # Wireless Lamp outlet relay + - xpath: './/product_airlink[@product_identifier="_0x4202"]' + node: 'airlink_relay' + # Wireless Combi relay 4 buttons + - xpath: './/product_airlink[@product_identifier="_0x4404"]' + node: 'airlink_relay' + # Dataline Lamp outlet + - xpath: './/product_dataline[@product_identifier="_0x2202"]' + node: 'dataline_output' + # Mobile Wireless dimmer + - xpath: './/product_airlink[@product_identifier="_0x4303"]' + node: 'airlink_dimming' + dimmable: True + +sensor: + # Temperature sensor + - xpath: './/product_dataline[@product_identifier="_0x2124"]' + node: 'resource_temperature' + unit_of_measurement: '°C' + # Humidity/temperature + - xpath: './/product_dataline[@product_identifier="_0x2135"]' + node: 'resource_humidity_level' + unit_of_measurement: '%' + # Humidity/temperature + - xpath: './/product_dataline[@product_identifier="_0x2135"]' + node: 'resource_temperature' + unit_of_measurement: '°C' + # Lux/temperature + - xpath: './/product_dataline[@product_identifier="_0x2136"]' + node: 'resource_light' + unit_of_measurement: 'Lux' + # Lux/temperature + - xpath: './/product_dataline[@product_identifier="_0x2136"]' + node: 'resource_temperature' + unit_of_measurement: '°C' + +switch: + # Wireless Plug outlet + - xpath: './/product_airlink[@product_identifier="_0x4201"]' + node: 'airlink_relay' + # Dataline universal relay + - xpath: './/product_airlink[@product_identifier="_0x4203"]' + node: 'airlink_relay' + # Dataline plug outlet + - xpath: './/product_dataline[@product_identifier="_0x2201"]' + node: 'dataline_output' + # Wireless mobile relay + - xpath: './/product_airlink[@product_identifier="_0x4204"]' + node: 'airlink_relay' + diff --git a/homeassistant/components/ihc/ihcdevice.py b/homeassistant/components/ihc/ihcdevice.py new file mode 100644 index 0000000000000..de6db875def00 --- /dev/null +++ b/homeassistant/components/ihc/ihcdevice.py @@ -0,0 +1,65 @@ +"""Implementation of a base class for all IHC devices.""" +import asyncio +from xml.etree.ElementTree import Element + +from homeassistant.helpers.entity import Entity + + +class IHCDevice(Entity): + """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. + Derived classes must implement the on_ihc_change method + """ + + def __init__(self, ihc_controller, name, ihc_id: int, info: bool, + product: Element = None) -> None: + """Initialize IHC attributes.""" + self.ihc_controller = ihc_controller + self._name = name + self.ihc_id = ihc_id + self.info = info + if product: + self.ihc_name = product.attrib['name'] + self.ihc_note = product.attrib['note'] + self.ihc_position = product.attrib['position'] + else: + self.ihc_name = '' + self.ihc_note = '' + self.ihc_position = '' + + @asyncio.coroutine + def async_added_to_hass(self): + """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.""" + return False + + @property + def name(self): + """Return the device name.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if not self.info: + return {} + return { + 'ihc_id': self.ihc_id, + 'ihc_name': self.ihc_name, + 'ihc_note': self.ihc_note, + 'ihc_position': self.ihc_position + } + + def on_ihc_change(self, ihc_id, value): + """Callback when IHC resource changes. + + Derived classes must overwrite this to do device specific stuff. + """ + raise NotImplementedError diff --git a/homeassistant/components/ihc/services.yaml b/homeassistant/components/ihc/services.yaml new file mode 100644 index 0000000000000..7b6053eff898a --- /dev/null +++ b/homeassistant/components/ihc/services.yaml @@ -0,0 +1,26 @@ +# Describes the format for available ihc services + +set_runtime_value_bool: + description: Set a boolean runtime value on the ihc controller + fields: + ihc_id: + description: The integer ihc resource id + value: + description: The boolean value to set + +set_runtime_value_int: + description: Set an integer runtime value on the ihc controller + fields: + ihc_id: + description: The integer ihc resource id + value: + description: The integer value to set + +set_runtime_value_float: + description: Set a float runtime value on the ihc controller + fields: + ihc_id: + description: The integer ihc resource id + value: + description: The float value to set + diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index e6979087b6f5b..29f26cc84e6c0 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -7,19 +7,18 @@ import asyncio from datetime import timedelta import logging -import os import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file 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__) @@ -36,7 +35,15 @@ 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' @@ -45,14 +52,14 @@ DEFAULT_CONFIDENCE = 80 SOURCE_SCHEMA = vol.Schema({ - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_domain('camera'), vol.Optional(CONF_NAME): cv.string, }) 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({ @@ -62,22 +69,18 @@ @bind_hass def scan(hass, entity_id=None): - """Force process a image.""" + """Force process an image.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None hass.services.call(DOMAIN, SERVICE_SCAN, data) @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) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine def async_scan_service(service): """Service handler for scan.""" @@ -90,7 +93,7 @@ def async_scan_service(service): hass.services.async_register( DOMAIN, SERVICE_SCAN, async_scan_service, - descriptions.get(SERVICE_SCAN), schema=SERVICE_SCAN_SCHEMA) + schema=SERVICE_SCAN_SCHEMA) return True @@ -127,16 +130,103 @@ def async_update(self): 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 788d12520f56a..e225113b5b175 100644 --- a/homeassistant/components/image_processing/demo.py +++ b/homeassistant/components/image_processing/demo.py @@ -4,11 +4,12 @@ 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 65705feb7f7d9..d4a20da253c4d 100644 --- a/homeassistant/components/image_processing/dlib_face_detect.py +++ b/homeassistant/components/image_processing/dlib_face_detect.py @@ -11,9 +11,7 @@ # 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 22594aa254700..bf34eb4c2dab7 100644 --- a/homeassistant/components/image_processing/dlib_face_identify.py +++ b/homeassistant/components/image_processing/dlib_face_identify.py @@ -11,9 +11,8 @@ 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 0000000000000..81b43c1f8e0ce --- /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 40aac61914b42..bda0e1bc550fe 100644 --- a/homeassistant/components/image_processing/microsoft_face_detect.py +++ b/homeassistant/components/image_processing/microsoft_face_detect.py @@ -9,13 +9,12 @@ 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'] @@ -36,7 +35,7 @@ def validate_attributes(list_attributes): """Validate face attributes.""" for attr in list_attributes: if attr not in SUPPORTED_ATTRIBUTES: - raise vol.Invalid("Invalid attribtue {0}".format(attr)) + raise vol.Invalid("Invalid attribute {0}".format(attr)) return list_attributes diff --git a/homeassistant/components/image_processing/microsoft_face_identify.py b/homeassistant/components/image_processing/microsoft_face_identify.py index 0cdd167527441..8984f25cdf2d4 100644 --- a/homeassistant/components/image_processing/microsoft_face_identify.py +++ b/homeassistant/components/image_processing/microsoft_face_identify.py @@ -9,30 +9,19 @@ 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.""" @@ -201,7 +103,7 @@ def async_process_image(self, image): return # Parse data - knwon_faces = [] + known_faces = [] total = 0 for face in detect: total += 1 @@ -215,9 +117,9 @@ def async_process_image(self, image): name = s_name break - knwon_faces.append({ + known_faces.append({ ATTR_NAME: name, ATTR_CONFIDENCE: data['confidence'] * 100, }) - self.async_process_faces(knwon_faces, total) + self.async_process_faces(known_faces, total) diff --git a/homeassistant/components/image_processing/openalpr_cloud.py b/homeassistant/components/image_processing/openalpr_cloud.py index 2fdc3d72f2ed3..dbf36dcd86ebe 100644 --- a/homeassistant/components/image_processing/openalpr_cloud.py +++ b/homeassistant/components/image_processing/openalpr_cloud.py @@ -109,12 +109,14 @@ def async_process_image(self, image): websession = async_get_clientsession(self.hass) params = self._params.copy() - params['image_bytes'] = str(b64encode(image), 'utf-8') + body = { + 'image_bytes': str(b64encode(image), 'utf-8') + } try: with async_timeout.timeout(self.timeout, loop=self.hass.loop): request = yield from websession.post( - OPENALPR_API_URL, params=params + OPENALPR_API_URL, params=params, data=body ) data = yield from request.json() diff --git a/homeassistant/components/image_processing/openalpr_local.py b/homeassistant/components/image_processing/openalpr_local.py index b0ef93611eaac..227e32696280d 100644 --- a/homeassistant/components/image_processing/openalpr_local.py +++ b/homeassistant/components/image_processing/openalpr_local.py @@ -13,11 +13,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.core import split_entity_id, callback -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, CONF_REGION from homeassistant.components.image_processing import ( PLATFORM_SCHEMA, ImageProcessingEntity, CONF_CONFIDENCE, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME, ATTR_ENTITY_ID, ATTR_CONFIDENCE) -from homeassistant.util.async import run_callback_threadsafe +from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -46,7 +46,6 @@ ] CONF_ALPR_BIN = 'alp_bin' -CONF_REGION = 'region' DEFAULT_BINARY = 'alpr' diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index 9cf3749de6bf8..c3e34b4d42be9 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -8,16 +8,15 @@ import logging import requests - import voluptuous as vol -from homeassistant.core import split_entity_id from homeassistant.components.image_processing import ( - CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME, PLATFORM_SCHEMA, + CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE, PLATFORM_SCHEMA, ImageProcessingEntity) +from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.13.1'] +REQUIREMENTS = ['numpy==1.14.3'] _LOGGER = logging.getLogger(__name__) @@ -28,7 +27,7 @@ 'https://raw.githubusercontent.com/opencv/opencv/master/data/' + \ 'lbpcascades/lbpcascade_frontalface.xml' -CONF_CLASSIFIER = 'classifer' +CONF_CLASSIFIER = 'classifier' CONF_FILE = 'file' CONF_MIN_SIZE = 'min_size' CONF_NEIGHBORS = 'neighbors' @@ -43,7 +42,7 @@ SCAN_INTERVAL = timedelta(seconds=2) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_CLASSIFIER, default=None): { + vol.Optional(CONF_CLASSIFIER): { cv.string: vol.Any( cv.isfile, vol.Schema({ @@ -61,7 +60,7 @@ def _create_processor_from_config(hass, camera_entity, config): """Create an OpenCV processor from configuration.""" - classifier_config = config[CONF_CLASSIFIER] + classifier_config = config.get(CONF_CLASSIFIER) name = '{} {}'.format( config[CONF_NAME], split_entity_id(camera_entity)[1].replace('_', ' ')) @@ -73,7 +72,7 @@ def _create_processor_from_config(hass, camera_entity, config): def _get_default_classifier(dest_path): """Download the default OpenCV classifier.""" - _LOGGER.info('Downloading default classifier') + _LOGGER.info("Downloading default classifier") req = requests.get(CASCADE_URL, stream=True) with open(dest_path, 'wb') as fil: for chunk in req.iter_content(chunk_size=1024): @@ -84,18 +83,17 @@ def _get_default_classifier(dest_path): def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the OpenCV image processing platform.""" try: - # Verify opencv python package is preinstalled + # Verify that the OpenCV python package is pre-installed # pylint: disable=unused-import,unused-variable import cv2 # noqa except ImportError: - _LOGGER.error("No opencv library found! " + - "Install or compile for your system " + - "following instructions here: " + - "http://opencv.org/releases.html") + _LOGGER.error( + "No OpenCV library found! Install or compile for your system " + "following instructions here: http://opencv.org/releases.html") return entities = [] - if config[CONF_CLASSIFIER] is None: + if CONF_CLASSIFIER not in config: dest_path = hass.config.path(DEFAULT_CLASSIFIER_PATH) _get_default_classifier(dest_path) config[CONF_CLASSIFIER] = { @@ -105,8 +103,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for camera in config[CONF_SOURCE]: entities.append(OpenCVImageProcessor( hass, camera[CONF_ENTITY_ID], camera.get(CONF_NAME), - config[CONF_CLASSIFIER] - )) + config[CONF_CLASSIFIER])) add_devices(entities) @@ -121,8 +118,7 @@ def __init__(self, hass, camera_entity, name, classifiers): if name: self._name = name else: - self._name = "OpenCV {0}".format( - split_entity_id(camera_entity)[1]) + self._name = "OpenCV {0}".format(split_entity_id(camera_entity)[1]) self._classifiers = classifiers self._matches = {} self._total_matches = 0 @@ -157,8 +153,8 @@ def process_image(self, image): import numpy # pylint: disable=no-member - cv_image = cv2.imdecode(numpy.asarray(bytearray(image)), - cv2.IMREAD_UNCHANGED) + cv_image = cv2.imdecode( + numpy.asarray(bytearray(image)), cv2.IMREAD_UNCHANGED) for name, classifier in self._classifiers.items(): scale = DEFAULT_SCALE diff --git a/homeassistant/components/image_processing/services.yaml b/homeassistant/components/image_processing/services.yaml index 2c6369f9804ea..1f1fa347dc9b5 100644 --- a/homeassistant/components/image_processing/services.yaml +++ b/homeassistant/components/image_processing/services.yaml @@ -1,9 +1,8 @@ -# Describes the format for available image_processing services +# Describes the format for available image processing services scan: - description: Process an image immediately - + description: Process an image immediately. fields: entity_id: - description: Name(s) of entities to scan immediately + description: Name(s) of entities to scan immediately. example: 'image_processing.alpr_garage' diff --git a/homeassistant/components/image_processing/seven_segments.py b/homeassistant/components/image_processing/seven_segments.py index d91f466604637..b49739bcec321 100644 --- a/homeassistant/components/image_processing/seven_segments.py +++ b/homeassistant/components/image_processing/seven_segments.py @@ -1,5 +1,5 @@ """ -Local optical character recognition processing of seven segements displays. +Local optical character recognition processing of seven segments displays. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/image_processing.seven_segments/ @@ -33,7 +33,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_EXTRA_ARGUMENTS, default=''): cv.string, - vol.Optional(CONF_DIGITS, default=-1): cv.positive_int, + vol.Optional(CONF_DIGITS): cv.positive_int, vol.Optional(CONF_HEIGHT, default=0): cv.positive_int, vol.Optional(CONF_SSOCR_BIN, default=DEFAULT_BINARY): cv.string, vol.Optional(CONF_THRESHOLD, default=0): cv.positive_int, @@ -66,14 +66,14 @@ def __init__(self, hass, camera_entity, config, name): if name: self._name = name else: - self._name = "SevenSegement OCR {0}".format( + self._name = "SevenSegment OCR {0}".format( split_entity_id(camera_entity)[1]) self._state = None self.filepath = os.path.join(self.hass.config.config_dir, 'ocr.png') crop = ['crop', str(config[CONF_X_POS]), str(config[CONF_Y_POS]), str(config[CONF_WIDTH]), str(config[CONF_HEIGHT])] - digits = ['-d', str(config[CONF_DIGITS])] + digits = ['-d', str(config.get(CONF_DIGITS, -1))] rotate = ['rotate', str(config[CONF_ROTATE])] threshold = ['-t', str(config[CONF_THRESHOLD])] extra_arguments = config[CONF_EXTRA_ARGUMENTS].split(' ') diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 36a58fa816558..6d54324542add 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -5,20 +5,25 @@ https://home-assistant.io/components/influxdb/ """ import logging - import re +import queue +import threading +import time +import math +import requests.exceptions import voluptuous as vol from homeassistant.const import ( - EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN, CONF_HOST, - CONF_PORT, CONF_SSL, CONF_VERIFY_SSL, CONF_USERNAME, CONF_PASSWORD, - CONF_EXCLUDE, CONF_INCLUDE, CONF_DOMAINS, CONF_ENTITIES) + CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, + CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, + EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE, + STATE_UNKNOWN) from homeassistant.helpers import state as state_helper -from homeassistant.helpers.entity_values import EntityValues import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_values import EntityValues -REQUIREMENTS = ['influxdb==3.0.0'] +REQUIREMENTS = ['influxdb==5.0.0'] _LOGGER = logging.getLogger(__name__) @@ -30,18 +35,25 @@ CONF_COMPONENT_CONFIG = 'component_config' CONF_COMPONENT_CONFIG_GLOB = 'component_config_glob' CONF_COMPONENT_CONFIG_DOMAIN = 'component_config_domain' +CONF_RETRY_COUNT = 'max_retries' DEFAULT_DATABASE = 'home_assistant' DEFAULT_VERIFY_SSL = True DOMAIN = 'influxdb' + TIMEOUT = 5 +RETRY_DELAY = 20 +QUEUE_BACKLOG_SECONDS = 30 + +BATCH_TIMEOUT = 1 +BATCH_BUFFER_SIZE = 100 COMPONENT_CONFIG_SCHEMA_ENTRY = vol.Schema({ vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string, }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ + DOMAIN: vol.All(vol.Schema({ vol.Optional(CONF_HOST): cv.string, vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, @@ -58,6 +70,7 @@ vol.Optional(CONF_DB_NAME, default=DEFAULT_DATABASE): cv.string, vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_SSL): cv.boolean, + vol.Optional(CONF_RETRY_COUNT, default=0): cv.positive_int, vol.Optional(CONF_DEFAULT_MEASUREMENT): cv.string, vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string, vol.Optional(CONF_TAGS, default={}): @@ -71,7 +84,7 @@ vol.Schema({cv.string: COMPONENT_CONFIG_SCHEMA_ENTRY}), vol.Optional(CONF_COMPONENT_CONFIG_DOMAIN, default={}): vol.Schema({cv.string: COMPONENT_CONFIG_SCHEMA_ENTRY}), - }), + })), }, extra=vol.ALLOW_EXTRA) RE_DIGIT_TAIL = re.compile(r'^[^\.]*\d+\.?\d+[^\.]*$') @@ -119,23 +132,25 @@ def setup(hass, config): conf[CONF_COMPONENT_CONFIG], conf[CONF_COMPONENT_CONFIG_DOMAIN], conf[CONF_COMPONENT_CONFIG_GLOB]) + max_tries = conf.get(CONF_RETRY_COUNT) try: influx = InfluxDBClient(**kwargs) influx.query("SHOW SERIES LIMIT 1;", database=conf[CONF_DB_NAME]) - except exceptions.InfluxDBClientError as exc: + except (exceptions.InfluxDBClientError, + requests.exceptions.ConnectionError) as exc: _LOGGER.error("Database host is not accessible due to '%s', please " - "check your entries in the configuration file and that " - "the database exists and is READ/WRITE.", exc) + "check your entries in the configuration file (host, " + "port, etc.) and verify that the database exists and is " + "READ/WRITE", exc) return False - def influx_event_listener(event): - """Listen for new messages on the bus and sends them to Influx.""" + def event_to_json(event): + """Add an event to the outgoing Influx list.""" state = event.data.get('new_state') if state is None or state.state in ( STATE_UNKNOWN, '', STATE_UNAVAILABLE) or \ - state.entity_id in blacklist_e or \ - state.domain in blacklist_d: + state.entity_id in blacklist_e or state.domain in blacklist_d: return try: @@ -143,12 +158,18 @@ def influx_event_listener(event): (whitelist_d and state.domain not in whitelist_d): return - _state = float(state_helper.state_as_number(state)) - _state_key = "value" + _include_state = _include_value = False + + _state_as_value = float(state.state) + _include_value = True except ValueError: - _state = state.state - _state_key = "state" + try: + _state_as_value = float(state_helper.state_as_number(state)) + _include_state = _include_value = True + except ValueError: + _include_state = True + include_uom = True measurement = component_config.get(state.entity_id).get( CONF_OVERRIDE_MEASUREMENT) if measurement in (None, ''): @@ -161,50 +182,162 @@ def influx_event_listener(event): measurement = default_measurement else: measurement = state.entity_id - - json_body = [ - { - 'measurement': measurement, - 'tags': { - 'domain': state.domain, - 'entity_id': state.object_id, - }, - 'time': event.time_fired, - 'fields': { - _state_key: _state, - } - } - ] + else: + include_uom = False + + json = { + 'measurement': measurement, + 'tags': { + 'domain': state.domain, + 'entity_id': state.object_id, + }, + 'time': event.time_fired, + 'fields': {} + } + if _include_state: + json['fields']['state'] = state.state + if _include_value: + json['fields']['value'] = _state_as_value for key, value in state.attributes.items(): if key in tags_attributes: - json_body[0]['tags'][key] = value - elif key != 'unit_of_measurement': + json['tags'][key] = value + elif key != 'unit_of_measurement' or include_uom: # If the key is already in fields - if key in json_body[0]['fields']: + if key in json['fields']: key = key + "_" # Prevent column data errors in influxDB. # For each value we try to cast it as float # But if we can not do it we store the value # as string add "_str" postfix to the field key try: - json_body[0]['fields'][key] = float(value) + json['fields'][key] = float(value) except (ValueError, TypeError): new_key = "{}_str".format(key) new_value = str(value) - json_body[0]['fields'][new_key] = new_value + json['fields'][new_key] = new_value if RE_DIGIT_TAIL.match(new_value): - json_body[0]['fields'][key] = float( + json['fields'][key] = float( RE_DECIMAL.sub('', new_value)) - json_body[0]['tags'].update(tags) + # 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 - try: - influx.write_points(json_body) - except exceptions.InfluxDBClientError: - _LOGGER.exception("Error saving event %s to InfluxDB", json_body) + json['tags'].update(tags) + + return json + + instance = hass.data[DOMAIN] = InfluxThread( + hass, influx, event_to_json, max_tries) + instance.start() + + def shutdown(event): + """Shut down the thread.""" + instance.queue.put(None) + instance.join() + influx.close() - hass.bus.listen(EVENT_STATE_CHANGED, influx_event_listener) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) return True + + +class InfluxThread(threading.Thread): + """A threaded event handler class.""" + + def __init__(self, hass, influx, event_to_json, max_tries): + """Initialize the listener.""" + threading.Thread.__init__(self, name='InfluxDB') + self.queue = queue.Queue() + self.influx = influx + self.event_to_json = event_to_json + self.max_tries = max_tries + self.write_errors = 0 + self.shutdown = False + hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener) + + def _event_listener(self, event): + """Listen for new messages on the bus and queue them for Influx.""" + item = (time.monotonic(), event) + self.queue.put(item) + + @staticmethod + def batch_timeout(): + """Return number of seconds to wait for more events.""" + return BATCH_TIMEOUT + + def get_events_json(self): + """Return a batch of events formatted for writing.""" + queue_seconds = QUEUE_BACKLOG_SECONDS + self.max_tries*RETRY_DELAY + + count = 0 + json = [] + + dropped = 0 + + try: + while len(json) < BATCH_BUFFER_SIZE and not self.shutdown: + timeout = None if count == 0 else self.batch_timeout() + item = self.queue.get(timeout=timeout) + count += 1 + + if item is None: + self.shutdown = True + else: + timestamp, event = item + age = time.monotonic() - timestamp + + if age < queue_seconds: + event_json = self.event_to_json(event) + if event_json: + json.append(event_json) + else: + dropped += 1 + + except queue.Empty: + pass + + if dropped: + _LOGGER.warning("Catching up, dropped %d old events", dropped) + + return count, json + + def write_to_influxdb(self, json): + """Write preprocessed events to influxdb, with retry.""" + from influxdb import exceptions + + for retry in range(self.max_tries+1): + try: + self.influx.write_points(json) + + if self.write_errors: + _LOGGER.error("Resumed, lost %d events", self.write_errors) + self.write_errors = 0 + + _LOGGER.debug("Wrote %d events", len(json)) + break + except (exceptions.InfluxDBClientError, IOError): + if retry < self.max_tries: + time.sleep(RETRY_DELAY) + else: + if not self.write_errors: + _LOGGER.exception("Write error") + self.write_errors += len(json) + + def run(self): + """Process incoming events.""" + while not self.shutdown: + count, json = self.get_events_json() + if json: + self.write_to_influxdb(json) + for _ in range(count): + self.queue.task_done() + + def block_till_done(self): + """Block till all events processed.""" + self.queue.join() diff --git a/homeassistant/components/input_boolean.py b/homeassistant/components/input_boolean.py index 3c4efdce17547..9c8435614a221 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -25,7 +25,6 @@ _LOGGER = logging.getLogger(__name__) CONF_INITIAL = 'initial' -DEFAULT_INITIAL = False SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, @@ -66,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) @@ -86,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) @@ -100,16 +97,19 @@ def async_handler_service(service): 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, schema=SERVICE_SCHEMA) + DOMAIN, SERVICE_TURN_OFF, async_handler_service, + schema=SERVICE_SCHEMA) hass.services.async_register( - DOMAIN, SERVICE_TURN_ON, async_handler_service, schema=SERVICE_SCHEMA) + DOMAIN, SERVICE_TURN_ON, async_handler_service, + schema=SERVICE_SCHEMA) hass.services.async_register( - DOMAIN, SERVICE_TOGGLE, async_handler_service, schema=SERVICE_SCHEMA) + DOMAIN, SERVICE_TOGGLE, async_handler_service, + schema=SERVICE_SCHEMA) - yield from component.async_add_entities(entities) + await component.async_add_entities(entities) return True @@ -135,7 +135,7 @@ def name(self): @property def icon(self): - """Returh the icon to be used for this entity.""" + """Return the icon to be used for this entity.""" return self._icon @property @@ -143,24 +143,21 @@ def is_on(self): """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/input_datetime.py b/homeassistant/components/input_datetime.py new file mode 100644 index 0000000000000..a77b67792f565 --- /dev/null +++ b/homeassistant/components/input_datetime.py @@ -0,0 +1,227 @@ +""" +Component to offer a way to select a date and / or a time. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/input_datetime/ +""" +import asyncio +import logging +import datetime + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_UNKNOWN) +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 +from homeassistant.util import dt as dt_util + + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'input_datetime' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_HAS_DATE = 'has_date' +CONF_HAS_TIME = 'has_time' +CONF_INITIAL = 'initial' + +ATTR_DATE = 'date' +ATTR_TIME = 'time' + +SERVICE_SET_DATETIME = 'set_datetime' + +SERVICE_SET_DATETIME_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_DATE): cv.date, + vol.Optional(ATTR_TIME): cv.time, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.All({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HAS_DATE, default=False): cv.boolean, + vol.Optional(CONF_HAS_TIME, default=False): cv.boolean, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL): cv.string, + }, cv.has_at_least_one_key_value((CONF_HAS_DATE, True), + (CONF_HAS_TIME, True)))}) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_set_datetime(hass, entity_id, dt_value): + """Set date and / or time of input_datetime.""" + yield from hass.services.async_call(DOMAIN, SERVICE_SET_DATETIME, { + ATTR_ENTITY_ID: entity_id, + ATTR_DATE: dt_value.date(), + ATTR_TIME: dt_value.time() + }) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up an input datetime.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME) + has_time = cfg.get(CONF_HAS_TIME) + has_date = cfg.get(CONF_HAS_DATE) + icon = cfg.get(CONF_ICON) + initial = cfg.get(CONF_INITIAL) + entities.append(InputDatetime(object_id, name, + has_date, has_time, icon, initial)) + + if not entities: + return False + + @asyncio.coroutine + def async_set_datetime_service(call): + """Handle a call to the input datetime 'set datetime' service.""" + target_inputs = component.async_extract_from_service(call) + + tasks = [] + for input_datetime in target_inputs: + time = call.data.get(ATTR_TIME) + date = call.data.get(ATTR_DATE) + if (input_datetime.has_date() and not date) or \ + (input_datetime.has_time() and not time): + _LOGGER.error("Invalid service data for " + "input_datetime.set_datetime: %s", + str(call.data)) + continue + + tasks.append(input_datetime.async_set_datetime(date, time)) + + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + hass.services.async_register( + DOMAIN, SERVICE_SET_DATETIME, async_set_datetime_service, + schema=SERVICE_SET_DATETIME_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class InputDatetime(Entity): + """Representation of a datetime input.""" + + def __init__(self, object_id, name, has_date, has_time, icon, initial): + """Initialize a select input.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._has_date = has_date + self._has_time = has_time + self._icon = icon + self._initial = initial + self._current_datetime = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Run when entity about to be added.""" + restore_val = None + + # Priority 1: Initial State + if self._initial is not None: + restore_val = self._initial + + # Priority 2: Old state + if restore_val is None: + old_state = yield from async_get_last_state(self.hass, + self.entity_id) + if old_state is not None: + restore_val = old_state.state + + if restore_val is not None: + if not self._has_date: + self._current_datetime = dt_util.parse_time(restore_val) + elif not self._has_time: + self._current_datetime = dt_util.parse_date(restore_val) + else: + self._current_datetime = dt_util.parse_datetime(restore_val) + + def has_date(self): + """Return whether the input datetime carries a date.""" + return self._has_date + + def has_time(self): + """Return whether the input datetime carries a time.""" + return self._has_time + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return the name of the select input.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the state of the component.""" + if self._current_datetime is None: + return STATE_UNKNOWN + + return self._current_datetime + + @property + def state_attributes(self): + """Return the state attributes.""" + attrs = { + 'has_date': self._has_date, + 'has_time': self._has_time, + } + + if self._current_datetime is None: + return attrs + + if self._has_date and self._current_datetime is not None: + attrs['year'] = self._current_datetime.year + attrs['month'] = self._current_datetime.month + attrs['day'] = self._current_datetime.day + + if self._has_time and self._current_datetime is not None: + attrs['hour'] = self._current_datetime.hour + attrs['minute'] = self._current_datetime.minute + attrs['second'] = self._current_datetime.second + + if self._current_datetime is not None: + if not self._has_date: + attrs['timestamp'] = self._current_datetime.hour * 3600 + \ + self._current_datetime.minute * 60 + \ + self._current_datetime.second + elif not self._has_time: + extended = datetime.datetime.combine(self._current_datetime, + datetime.time(0, 0)) + attrs['timestamp'] = extended.timestamp() + else: + attrs['timestamp'] = self._current_datetime.timestamp() + + return attrs + + @asyncio.coroutine + def async_set_datetime(self, date_val, time_val): + """Set a new date / time.""" + if self._has_date and self._has_time and date_val and time_val: + self._current_datetime = datetime.datetime.combine(date_val, + time_val) + elif self._has_date and not self._has_time and date_val: + self._current_datetime = date_val + if self._has_time and not self._has_date and time_val: + self._current_datetime = time_val + + yield from self.async_update_ha_state() diff --git a/homeassistant/components/input_slider.py b/homeassistant/components/input_number.py similarity index 55% rename from homeassistant/components/input_slider.py rename to homeassistant/components/input_number.py index 5357878a0cef4..e18169fca7316 100644 --- a/homeassistant/components/input_slider.py +++ b/homeassistant/components/input_number.py @@ -1,8 +1,8 @@ """ -Component to offer a way to select a value from a slider. +Component to offer a way to set a numeric value from a slider or text box. For more details about this component, please refer to the documentation -at https://home-assistant.io/components/input_slider/ +at https://home-assistant.io/components/input_number/ """ import asyncio import logging @@ -11,15 +11,15 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME) -from homeassistant.loader import bind_hass + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME, CONF_MODE) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) -DOMAIN = 'input_slider' +DOMAIN = 'input_number' ENTITY_ID_FORMAT = DOMAIN + '.{}' CONF_INITIAL = 'initial' @@ -27,21 +27,31 @@ CONF_MAX = 'max' CONF_STEP = 'step' +MODE_SLIDER = 'slider' +MODE_BOX = 'box' + ATTR_VALUE = 'value' ATTR_MIN = 'min' ATTR_MAX = 'max' ATTR_STEP = 'step' +ATTR_MODE = 'mode' -SERVICE_SELECT_VALUE = 'select_value' +SERVICE_SET_VALUE = 'set_value' +SERVICE_INCREMENT = 'increment' +SERVICE_DECREMENT = 'decrement' -SERVICE_SELECT_VALUE_SCHEMA = vol.Schema({ +SERVICE_DEFAULT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids +}) + +SERVICE_SET_VALUE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_VALUE): vol.Coerce(float), }) -def _cv_input_slider(cfg): - """Configure validation helper for input slider (voluptuous).""" +def _cv_input_number(cfg): + """Configure validation helper for input number (voluptuous).""" minimum = cfg.get(CONF_MIN) maximum = cfg.get(CONF_MAX) if minimum >= maximum: @@ -64,21 +74,52 @@ def _cv_input_slider(cfg): vol.Optional(CONF_STEP, default=1): vol.All(vol.Coerce(float), vol.Range(min=1e-3)), vol.Optional(CONF_ICON): cv.icon, - vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string - }, _cv_input_slider) + vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_MODE, default=MODE_SLIDER): + vol.In([MODE_BOX, MODE_SLIDER]), + }, _cv_input_number) }) }, required=True, extra=vol.ALLOW_EXTRA) +SERVICE_TO_METHOD = { + SERVICE_SET_VALUE: { + 'method': 'async_set_value', + 'schema': SERVICE_SET_VALUE_SCHEMA}, + SERVICE_INCREMENT: { + 'method': 'async_increment', + 'schema': SERVICE_DEFAULT_SCHEMA}, + SERVICE_DECREMENT: { + 'method': 'async_decrement', + 'schema': SERVICE_DEFAULT_SCHEMA}, +} + + @bind_hass -def select_value(hass, entity_id, value): - """Set input_slider to value.""" - hass.services.call(DOMAIN, SERVICE_SELECT_VALUE, { +def set_value(hass, entity_id, value): + """Set input_number to value.""" + hass.services.call(DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value, }) +@bind_hass +def increment(hass, entity_id): + """Increment value of entity.""" + hass.services.call(DOMAIN, SERVICE_INCREMENT, { + ATTR_ENTITY_ID: entity_id + }) + + +@bind_hass +def decrement(hass, entity_id): + """Decrement value of entity.""" + hass.services.call(DOMAIN, SERVICE_DECREMENT, { + ATTR_ENTITY_ID: entity_id + }) + + @asyncio.coroutine def async_setup(hass, config): """Set up an input slider.""" @@ -94,37 +135,48 @@ def async_setup(hass, config): step = cfg.get(CONF_STEP) icon = cfg.get(CONF_ICON) unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) + mode = cfg.get(CONF_MODE) - entities.append(InputSlider( - object_id, name, initial, minimum, maximum, step, icon, unit)) + entities.append(InputNumber( + object_id, name, initial, minimum, maximum, step, icon, unit, + mode)) if not entities: return False @asyncio.coroutine - def async_select_value_service(call): - """Handle a calls to the input slider services.""" - target_inputs = component.async_extract_from_service(call) - - tasks = [input_slider.async_select_value(call.data[ATTR_VALUE]) - for input_slider in target_inputs] - if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) - - hass.services.async_register( - DOMAIN, SERVICE_SELECT_VALUE, async_select_value_service, - schema=SERVICE_SELECT_VALUE_SCHEMA) + def async_handle_service(service): + """Handle calls to input_number services.""" + target_inputs = component.async_extract_from_service(service) + method = SERVICE_TO_METHOD.get(service.service) + params = service.data.copy() + params.pop(ATTR_ENTITY_ID, None) + + # call method + update_tasks = [] + for target_input in target_inputs: + yield from getattr(target_input, method['method'])(**params) + if not target_input.should_poll: + continue + update_tasks.append(target_input.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) + + for service, data in SERVICE_TO_METHOD.items(): + hass.services.async_register( + DOMAIN, service, async_handle_service, schema=data['schema']) yield from component.async_add_entities(entities) return True -class InputSlider(Entity): - """Represent an slider.""" +class InputNumber(Entity): + """Representation of a slider.""" def __init__(self, object_id, name, initial, minimum, maximum, step, icon, - unit): - """Initialize a select input.""" + unit, mode): + """Initialize an input number.""" self.entity_id = ENTITY_ID_FORMAT.format(object_id) self._name = name self._current_value = initial @@ -133,6 +185,7 @@ def __init__(self, object_id, name, initial, minimum, maximum, step, icon, self._step = step self._icon = icon self._unit = unit + self._mode = mode @property def should_poll(self): @@ -141,7 +194,7 @@ def should_poll(self): @property def name(self): - """Return the name of the select input slider.""" + """Return the name of the input slider.""" return self._name @property @@ -165,7 +218,8 @@ def state_attributes(self): return { ATTR_MIN: self._minimum, ATTR_MAX: self._maximum, - ATTR_STEP: self._step + ATTR_STEP: self._step, + ATTR_MODE: self._mode, } @asyncio.coroutine @@ -184,8 +238,8 @@ def async_added_to_hass(self): self._current_value = self._minimum @asyncio.coroutine - def async_select_value(self, value): - """Select new value.""" + def async_set_value(self, value): + """Set new value.""" num_value = float(value) if num_value < self._minimum or num_value > self._maximum: _LOGGER.warning("Invalid value: %s (range %s - %s)", @@ -193,3 +247,25 @@ def async_select_value(self, value): return self._current_value = num_value yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_increment(self): + """Increment value.""" + new_value = self._current_value + self._step + if new_value > self._maximum: + _LOGGER.warning("Invalid value: %s (range %s - %s)", + new_value, self._minimum, self._maximum) + return + self._current_value = new_value + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_decrement(self): + """Decrement value.""" + new_value = self._current_value - self._step + if new_value < self._minimum: + _LOGGER.warning("Invalid value: %s (range %s - %s)", + new_value, self._minimum, self._maximum) + return + self._current_value = new_value + yield from self.async_update_ha_state() diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py old mode 100755 new mode 100644 index 583181fe453ec..6433a01fb6d42 --- a/homeassistant/components/input_text.py +++ b/homeassistant/components/input_text.py @@ -11,7 +11,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME) + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME, CONF_MODE) from homeassistant.loader import bind_hass from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent @@ -26,10 +26,14 @@ CONF_MIN = 'min' CONF_MAX = 'max' +MODE_TEXT = 'text' +MODE_PASSWORD = 'password' + ATTR_VALUE = 'value' ATTR_MIN = 'min' ATTR_MAX = 'max' ATTR_PATTERN = 'pattern' +ATTR_MODE = 'mode' SERVICE_SET_VALUE = 'set_value' @@ -63,6 +67,8 @@ def _cv_input_text(cfg): vol.Optional(CONF_ICON): cv.icon, vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(ATTR_PATTERN): cv.string, + vol.Optional(CONF_MODE, default=MODE_TEXT): + vol.In([MODE_TEXT, MODE_PASSWORD]), }, _cv_input_text) }) }, required=True, extra=vol.ALLOW_EXTRA) @@ -92,10 +98,11 @@ def async_setup(hass, config): icon = cfg.get(CONF_ICON) unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) pattern = cfg.get(ATTR_PATTERN) + mode = cfg.get(CONF_MODE) entities.append(InputText( object_id, name, initial, minimum, maximum, icon, unit, - pattern)) + pattern, mode)) if not entities: return False @@ -122,7 +129,7 @@ class InputText(Entity): """Represent a text box.""" def __init__(self, object_id, name, initial, minimum, maximum, icon, - unit, pattern): + unit, pattern, mode): """Initialize a text input.""" self.entity_id = ENTITY_ID_FORMAT.format(object_id) self._name = name @@ -132,6 +139,7 @@ def __init__(self, object_id, name, initial, minimum, maximum, icon, self._icon = icon self._unit = unit self._pattern = pattern + self._mode = mode @property def should_poll(self): @@ -165,6 +173,7 @@ def state_attributes(self): ATTR_MIN: self._minimum, ATTR_MAX: self._maximum, ATTR_PATTERN: self._pattern, + ATTR_MODE: self._mode, } @asyncio.coroutine diff --git a/homeassistant/components/insteon_local.py b/homeassistant/components/insteon_local.py index 711dafb6b7390..a18d4e0aa1473 100644 --- a/homeassistant/components/insteon_local.py +++ b/homeassistant/components/insteon_local.py @@ -11,10 +11,11 @@ import voluptuous as vol from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, CONF_HOST, CONF_PORT, CONF_TIMEOUT) + CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_TIMEOUT, CONF_USERNAME) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform -REQUIREMENTS = ['insteonlocal==0.52'] +REQUIREMENTS = ['insteonlocal==0.53'] _LOGGER = logging.getLogger(__name__) @@ -22,22 +23,27 @@ DEFAULT_TIMEOUT = 10 DOMAIN = 'insteon_local' +INSTEON_CACHE = '.insteon_local_cache' + +INSTEON_PLATFORMS = [ + 'light', + 'switch', + 'fan', +] + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, }) }, extra=vol.ALLOW_EXTRA) def setup(hass, config): - """Set up the Insteon Hub component. - - This will automatically import associated lights. - """ + """Set up the local Insteon hub.""" from insteonlocal.Hub import Hub conf = config[DOMAIN] @@ -48,29 +54,33 @@ def setup(hass, config): timeout = conf.get(CONF_TIMEOUT) try: - if not os.path.exists(hass.config.path('.insteon_cache')): - os.makedirs(hass.config.path('.insteon_cache')) + if not os.path.exists(hass.config.path(INSTEON_CACHE)): + os.makedirs(hass.config.path(INSTEON_CACHE)) insteonhub = Hub(host, username, password, port, timeout, _LOGGER, - hass.config.path('.insteon_cache')) + hass.config.path(INSTEON_CACHE)) # Check for successful connection insteonhub.get_buffer_status() except requests.exceptions.ConnectTimeout: - _LOGGER.error("Error on insteon_local." - "Could not connect. Check config", exc_info=True) + _LOGGER.error("Could not connect", exc_info=True) return False except requests.exceptions.ConnectionError: - _LOGGER.error("Error on insteon_local. Could not connect." - "Check config", exc_info=True) + _LOGGER.error("Could not connect", exc_info=True) return False except requests.exceptions.RequestException: if insteonhub.http_code == 401: - _LOGGER.error("Bad user/pass for insteon_local hub") + _LOGGER.error("Bad username or password for Insteon_local hub") else: - _LOGGER.error("Error on insteon_local hub check", exc_info=True) + _LOGGER.error("Error on Insteon_local hub check", exc_info=True) return False + linked = insteonhub.get_linked() + hass.data['insteon_local'] = insteonhub + for insteon_platform in INSTEON_PLATFORMS: + load_platform(hass, insteon_platform, DOMAIN, {'linked': linked}, + config) + return True diff --git a/homeassistant/components/insteon_plm.py b/homeassistant/components/insteon_plm.py deleted file mode 100644 index 94b70e47cba81..0000000000000 --- a/homeassistant/components/insteon_plm.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Support for INSTEON PowerLinc Modem. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/insteon_plm/ -""" -import logging -import asyncio - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.const import ( - CONF_PORT, EVENT_HOMEASSISTANT_STOP) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import discovery - -REQUIREMENTS = ['insteonplm==0.7.5'] - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'insteon_plm' - -CONF_OVERRIDE = 'device_override' - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_PORT): cv.string, - vol.Optional(CONF_OVERRIDE, default=[]): vol.All( - cv.ensure_list_csv, vol.Length(min=1)) - }) -}, extra=vol.ALLOW_EXTRA) - -PLM_PLATFORMS = { - 'binary_sensor': ['binary_sensor'], - 'light': ['light'], - 'switch': ['switch'], -} - - -@asyncio.coroutine -def async_setup(hass, config): - """Set up the connection to the PLM.""" - import insteonplm - - conf = config[DOMAIN] - port = conf.get(CONF_PORT) - overrides = conf.get(CONF_OVERRIDE) - - @callback - def async_plm_new_device(device): - """Detect device from transport to be delegated to platform.""" - name = device.get('address') - address = device.get('address_hex') - capabilities = device.get('capabilities', []) - - _LOGGER.info("New INSTEON PLM device: %s (%s) %r", - name, address, capabilities) - - loadlist = [] - for platform in PLM_PLATFORMS: - caplist = PLM_PLATFORMS.get(platform) - for key in capabilities: - if key in caplist: - loadlist.append(platform) - - loadlist = sorted(set(loadlist)) - - for loadplatform in loadlist: - hass.async_add_job( - discovery.async_load_platform( - hass, loadplatform, DOMAIN, discovered=[device], - hass_config=config)) - - _LOGGER.info("Looking for PLM on %s", port) - plm = yield from insteonplm.Connection.create(device=port, loop=hass.loop) - - for device in overrides: - # - # Override the device default capabilities for a specific address - # - if isinstance(device['platform'], list): - plm.protocol.devices.add_override( - device['address'], 'capabilities', device['platform']) - else: - plm.protocol.devices.add_override( - device['address'], 'capabilities', [device['platform']]) - - hass.data['insteon_plm'] = plm - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, plm.close) - - plm.protocol.devices.add_device_callback(async_plm_new_device, {}) - - return True - - -def common_attributes(entity): - """Return the device state attributes.""" - attributes = {} - attributekeys = { - 'address': 'INSTEON Address', - 'description': 'Description', - 'model': 'Model', - 'cat': 'Category', - 'subcat': 'Subcategory', - 'firmware': 'Firmware', - 'product_key': 'Product Key' - } - - hexkeys = ['cat', 'subcat', 'firmware'] - - for key in attributekeys: - name = attributekeys[key] - val = entity.get_attr(key) - if val is not None: - if key in hexkeys: - attributes[name] = hex(int(val)) - else: - attributes[name] = val - return attributes diff --git a/homeassistant/components/insteon_plm/__init__.py b/homeassistant/components/insteon_plm/__init__.py new file mode 100644 index 0000000000000..246e84ec71f3a --- /dev/null +++ b/homeassistant/components/insteon_plm/__init__.py @@ -0,0 +1,347 @@ +""" +Support for INSTEON PowerLinc Modem. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/insteon_plm/ +""" +import asyncio +import collections +import logging +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import (CONF_PORT, EVENT_HOMEASSISTANT_STOP, + 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.9.1'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'insteon_plm' + +CONF_OVERRIDE = 'device_override' +CONF_ADDRESS = 'address' +CONF_CAT = 'cat' +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, + vol.Optional(CONF_CAT): cv.byte, + vol.Optional(CONF_SUBCAT): cv.byte, + vol.Optional(CONF_FIRMWARE): cv.byte, + vol.Optional(CONF_PRODUCT_KEY): cv.byte, + vol.Optional(CONF_PLATFORM): cv.string, + })) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PORT): cv.string, + vol.Optional(CONF_OVERRIDE): vol.All( + cv.ensure_list_csv, [CONF_DEVICE_OVERRIDE_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): + """Set up the connection to the PLM.""" + import insteonplm + + ipdb = IPDB() + plm = None + + conf = config[DOMAIN] + port = conf.get(CONF_PORT) + overrides = conf.get(CONF_OVERRIDE, []) + + @callback + def async_plm_new_device(device): + """Detect device from transport to be delegated to platform.""" + for state_key in device.states: + platform_info = ipdb[device.states[state_key]] + 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)) + + 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( + device=port, + loop=hass.loop, + workdir=hass.config.config_dir) + + plm = conn.protocol + + for device_override in overrides: + # + # Override the device default capabilities for a specific address + # + address = device_override.get('address') + for prop in device_override: + if prop in [CONF_CAT, CONF_SUBCAT]: + plm.devices.add_override(address, prop, + device_override[prop]) + elif prop in [CONF_FIRMWARE, CONF_PRODUCT_KEY]: + plm.devices.add_override(address, CONF_PRODUCT_KEY, + device_override[prop]) + + 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 + + +State = collections.namedtuple('Product', 'stateType platform') + + +class IPDB(object): + """Embodies the INSTEON Product Database static data and access methods.""" + + def __init__(self): + """Create the INSTEON Product Database (IPDB).""" + from insteonplm.states.onOff import (OnOffSwitch, + OnOffSwitch_OutletTop, + OnOffSwitch_OutletBottom, + OpenClosedRelay) + + from insteonplm.states.dimmable import (DimmableSwitch, + DimmableSwitch_Fan) + + from insteonplm.states.sensor import (VariableSensor, + OnOffSensor, + SmokeCO2Sensor, + 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'), + State(VariableSensor, 'sensor'), + + State(DimmableSwitch_Fan, 'fan'), + State(DimmableSwitch, 'light')] + + def __len__(self): + """Return the number of INSTEON state types mapped to HA platforms.""" + return len(self.states) + + def __iter__(self): + """Itterate through the INSTEON state types to HA platforms.""" + for product in self.states: + yield product + + def __getitem__(self, key): + """Return a Home Assistant platform from an INSTEON state type.""" + for state in self.states: + if isinstance(key, state.stateType): + return state + return None + + +class InsteonPLMEntity(Entity): + """INSTEON abstract base entity.""" + + def __init__(self, device, state_key): + """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): + """No polling needed.""" + return False + + @property + def address(self): + """Return the address of the node.""" + return self._insteon_device.address.human + + @property + def group(self): + """Return the INSTEON group that the entity responds to.""" + return self._insteon_device_state.group + + @property + def name(self): + """Return the name of the node (used for Entity_ID).""" + name = '' + if self._insteon_device_state.group == 0x01: + name = self._insteon_device.id + else: + name = '{:s}_{:d}'.format(self._insteon_device.id, + self._insteon_device_state.group) + return name + + @property + def device_state_attributes(self): + """Provide attributes for display on device card.""" + attributes = { + 'INSTEON Address': self.address, + 'INSTEON Group': self.group + } + return attributes + + @callback + def async_entity_update(self, deviceid, statename, val): + """Receive notification from transport that new data exists.""" + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_added_to_hass(self): + """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 0000000000000..9ea53c10fbf1a --- /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/introduction.py b/homeassistant/components/introduction.py index 367eeb1a6c3f8..cc3e00c447527 100644 --- a/homeassistant/components/introduction.py +++ b/homeassistant/components/introduction.py @@ -4,6 +4,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/introduction/ """ +import asyncio import logging import voluptuous as vol @@ -15,7 +16,8 @@ }, extra=vol.ALLOW_EXTRA) -def setup(hass, config=None): +@asyncio.coroutine +def async_setup(hass, config=None): """Set up the introduction component.""" log = logging.getLogger(__name__) log.info(""" @@ -46,4 +48,16 @@ def setup(hass, config=None): ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ """) + hass.components.persistent_notification.async_create(""" +Here are some resources to get started: + + - [Configuring Home Assistant](https://home-assistant.io/getting-started/configuration/) + - [Available components](https://home-assistant.io/components/) + - [Troubleshooting your configuration](https://home-assistant.io/docs/configuration/troubleshooting/) + - [Getting help](https://home-assistant.io/help/) + +To not see this card popup in the future, edit your config in +`configuration.yaml` and disable the `introduction` component. +""", 'Welcome Home!') # noqa + return True diff --git a/homeassistant/components/ios.py b/homeassistant/components/ios.py index 13ccee9df3e30..fe3c934659b92 100644 --- a/homeassistant/components/ios.py +++ b/homeassistant/components/ios.py @@ -5,26 +5,21 @@ https://home-assistant.io/ecosystem/ios/ """ import asyncio -import os -import json import logging import datetime import voluptuous as vol # from voluptuous.humanize import humanize_error -from homeassistant.helpers import config_validation as cv - -from homeassistant.helpers import discovery - -from homeassistant.core import callback - from homeassistant.components.http import HomeAssistantView - -from homeassistant.remote import JSONEncoder - from homeassistant.const import (HTTP_INTERNAL_SERVER_ERROR, HTTP_BAD_REQUEST) +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.util.json import load_json, save_json + _LOGGER = logging.getLogger(__name__) @@ -77,7 +72,7 @@ ATTR_DEVICE_TYPE = 'type' ATTR_DEVICE_SYSTEM_NAME = 'systemName' -ATTR_APP_BUNDLE_IDENTIFER = 'bundleIdentifer' +ATTR_APP_BUNDLE_IDENTIFIER = 'bundleIdentifier' ATTR_APP_BUILD_NUMBER = 'buildNumber' ATTR_APP_VERSION_NUMBER = 'versionNumber' @@ -121,7 +116,7 @@ CONF_PUSH: { CONF_PUSH_CATEGORIES: vol.All(cv.ensure_list, [{ vol.Required(CONF_PUSH_CATEGORIES_NAME): cv.string, - vol.Required(CONF_PUSH_CATEGORIES_IDENTIFIER): vol.Upper, + vol.Required(CONF_PUSH_CATEGORIES_IDENTIFIER): vol.Lower, vol.Required(CONF_PUSH_CATEGORIES_ACTIONS): ACTION_SCHEMA_LIST }]) } @@ -141,7 +136,7 @@ IDENTIFY_DEVICE_SCHEMA_CONTAINER = vol.All(dict, IDENTIFY_DEVICE_SCHEMA) IDENTIFY_APP_SCHEMA = vol.Schema({ - vol.Required(ATTR_APP_BUNDLE_IDENTIFER): cv.string, + vol.Required(ATTR_APP_BUNDLE_IDENTIFIER): cv.string, vol.Required(ATTR_APP_BUILD_NUMBER): cv.positive_int, vol.Optional(ATTR_APP_VERSION_NUMBER): cv.string }, extra=vol.ALLOW_EXTRA) @@ -174,36 +169,6 @@ CONFIG_FILE_PATH = "" -def _load_config(filename): - """Load configuration.""" - if not os.path.isfile(filename): - return {} - - try: - with open(filename, "r") as fdesc: - inp = fdesc.read() - - # In case empty file - if not inp: - return {} - - return json.loads(inp) - except (IOError, ValueError) as error: - _LOGGER.error("Reading config file %s failed: %s", filename, error) - return None - - -def _save_config(filename, config): - """Save configuration.""" - try: - with open(filename, 'w') as fdesc: - fdesc.write(json.dumps(config, cls=JSONEncoder)) - except (IOError, TypeError) as error: - _LOGGER.error("Saving config file failed: %s", error) - return False - return True - - def devices_with_push(): """Return a dictionary of push enabled targets.""" targets = {} @@ -217,7 +182,7 @@ def enabled_push_ids(): """Return a list of push enabled target push IDs.""" push_ids = list() # pylint: disable=unused-variable - for device_name, device in CONFIG_FILE[ATTR_DEVICES].items(): + for device in CONFIG_FILE[ATTR_DEVICES].values(): if device.get(ATTR_PUSH_ID) is not None: push_ids.append(device.get(ATTR_PUSH_ID)) return push_ids @@ -244,7 +209,7 @@ def setup(hass, config): CONFIG_FILE_PATH = hass.config.path(CONFIGURATION_FILE) - CONFIG_FILE = _load_config(CONFIG_FILE_PATH) + CONFIG_FILE = load_json(CONFIG_FILE_PATH) if CONFIG_FILE == {}: CONFIG_FILE[ATTR_DEVICES] = {} @@ -299,13 +264,15 @@ def post(self, request): # return self.json_message(humanize_error(request.json, ex), # HTTP_BAD_REQUEST) - data[ATTR_LAST_SEEN_AT] = datetime.datetime.now() + data[ATTR_LAST_SEEN_AT] = datetime.datetime.now().isoformat() name = data.get(ATTR_DEVICE_ID) CONFIG_FILE[ATTR_DEVICES][name] = data - if not _save_config(CONFIG_FILE_PATH, CONFIG_FILE): + try: + save_json(CONFIG_FILE_PATH, CONFIG_FILE) + except HomeAssistantError: return self.json_message("Error saving device.", HTTP_INTERNAL_SERVER_ERROR) diff --git a/homeassistant/components/iota.py b/homeassistant/components/iota.py new file mode 100644 index 0000000000000..ada70f8a9ebde --- /dev/null +++ b/homeassistant/components/iota.py @@ -0,0 +1,81 @@ +""" +Support for IOTA wallets. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/iota/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['pyota==2.0.5'] + +_LOGGER = logging.getLogger(__name__) + +CONF_IRI = 'iri' +CONF_TESTNET = 'testnet' +CONF_WALLET_NAME = 'name' +CONF_WALLET_SEED = 'seed' +CONF_WALLETS = 'wallets' + +DOMAIN = 'iota' + +IOTA_PLATFORMS = ['sensor'] + +SCAN_INTERVAL = timedelta(minutes=10) + +WALLET_CONFIG = vol.Schema({ + vol.Required(CONF_WALLET_NAME): cv.string, + vol.Required(CONF_WALLET_SEED): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_IRI): cv.string, + vol.Optional(CONF_TESTNET, default=False): cv.boolean, + vol.Required(CONF_WALLETS): vol.All(cv.ensure_list, [WALLET_CONFIG]), + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the IOTA component.""" + iota_config = config[DOMAIN] + + for platform in IOTA_PLATFORMS: + load_platform(hass, platform, DOMAIN, iota_config, config) + + return True + + +class IotaDevice(Entity): + """Representation of a IOTA device.""" + + def __init__(self, name, seed, iri, is_testnet=False): + """Initialisation of the IOTA device.""" + self._name = name + self._seed = seed + self.iri = iri + self.is_testnet = is_testnet + + @property + def name(self): + """Return the default name of the device.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {CONF_WALLET_NAME: self._name} + return attr + + @property + def api(self): + """Construct API object for interaction with the IRI node.""" + from iota import Iota + return Iota(adapter=self.iri, seed=self._seed) diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 7686eb7dc7de8..48a9499d1a9e9 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -4,6 +4,7 @@ For configuration details please visit the documentation for this component at https://home-assistant.io/components/isy994/ """ +import asyncio from collections import namedtuple import logging from urllib.parse import urlparse @@ -17,21 +18,20 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, Dict # noqa -REQUIREMENTS = ['PyISY==1.0.8'] +REQUIREMENTS = ['PyISY==1.1.0'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'isy994' -CONF_HIDDEN_STRING = 'hidden_string' +CONF_IGNORE_STRING = 'ignore_string' CONF_SENSOR_STRING = 'sensor_string' +CONF_ENABLE_CLIMATE = 'enable_climate' CONF_TLS_VER = 'tls' -DEFAULT_HIDDEN_STRING = '{HIDE ME}' +DEFAULT_IGNORE_STRING = '{IGNORE ME}' DEFAULT_SENSOR_STRING = 'sensor' -ISY = None - KEY_ACTIONS = 'actions' KEY_FOLDER = 'folder' KEY_MY_PROGRAMS = 'My Programs' @@ -43,162 +43,354 @@ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_TLS_VER): vol.Coerce(float), - vol.Optional(CONF_HIDDEN_STRING, - default=DEFAULT_HIDDEN_STRING): cv.string, + vol.Optional(CONF_IGNORE_STRING, + default=DEFAULT_IGNORE_STRING): cv.string, vol.Optional(CONF_SENSOR_STRING, - default=DEFAULT_SENSOR_STRING): cv.string + default=DEFAULT_SENSOR_STRING): cv.string, + vol.Optional(CONF_ENABLE_CLIMATE, + default=True): cv.boolean }) }, extra=vol.ALLOW_EXTRA) -SENSOR_NODES = [] -WEATHER_NODES = [] -NODES = [] -GROUPS = [] -PROGRAMS = {} +# Do not use the Hass consts for the states here - we're matching exact API +# responses, not using them for Hass states +NODE_FILTERS = { + 'binary_sensor': { + 'uom': [], + 'states': [], + 'node_def_id': ['BinaryAlarm'], + 'insteon_type': ['16.'] # Does a startswith() match; include the dot + }, + 'sensor': { + # This is just a more-readable way of including MOST uoms between 1-100 + # (Remember that range() is non-inclusive of the stop value) + 'uom': (['1'] + + list(map(str, range(3, 11))) + + list(map(str, range(12, 51))) + + list(map(str, range(52, 66))) + + list(map(str, range(69, 78))) + + ['79'] + + list(map(str, range(82, 97)))), + 'states': [], + 'node_def_id': ['IMETER_SOLO'], + 'insteon_type': ['9.0.', '9.7.'] + }, + 'lock': { + 'uom': ['11'], + 'states': ['locked', 'unlocked'], + 'node_def_id': ['DoorLock'], + 'insteon_type': ['15.'] + }, + 'fan': { + 'uom': [], + 'states': ['off', 'low', 'med', 'high'], + 'node_def_id': ['FanLincMotor'], + 'insteon_type': ['1.46.'] + }, + 'cover': { + 'uom': ['97'], + 'states': ['open', 'closed', 'closing', 'opening', 'stopped'], + 'node_def_id': [], + 'insteon_type': [] + }, + 'light': { + 'uom': ['51'], + 'states': ['on', 'off', '%'], + 'node_def_id': ['DimmerLampSwitch', 'DimmerLampSwitch_ADV', + 'DimmerSwitchOnly', 'DimmerSwitchOnly_ADV', + 'DimmerLampOnly', 'BallastRelayLampSwitch', + 'BallastRelayLampSwitch_ADV', + 'RemoteLinc2', 'RemoteLinc2_ADV'], + 'insteon_type': ['1.'] + }, + 'switch': { + 'uom': ['2', '78'], + 'states': ['on', 'off'], + 'node_def_id': ['OnOffControl', 'RelayLampSwitch', + 'RelayLampSwitch_ADV', 'RelaySwitchOnlyPlusQuery', + 'RelaySwitchOnlyPlusQuery_ADV', 'RelayLampOnly', + 'RelayLampOnly_ADV', 'KeypadButton', + 'KeypadButton_ADV', 'EZRAIN_Input', 'EZRAIN_Output', + 'EZIO2x4_Input', 'EZIO2x4_Input_ADV', 'BinaryControl', + 'BinaryControl_ADV', 'AlertModuleSiren', + 'AlertModuleSiren_ADV', 'AlertModuleArmed', 'Siren', + 'Siren_ADV'], + 'insteon_type': ['2.', '9.10.', '9.11.'] + } +} + +SUPPORTED_DOMAINS = ['binary_sensor', 'sensor', 'lock', 'fan', 'cover', + 'light', 'switch'] +SUPPORTED_PROGRAM_DOMAINS = ['binary_sensor', 'lock', 'fan', 'cover', 'switch'] + +# ISY Scenes are more like Switches than Hass Scenes +# (they can turn off, and report their state) +SCENE_DOMAIN = 'switch' + +ISY994_NODES = "isy994_nodes" +ISY994_WEATHER = "isy994_weather" +ISY994_PROGRAMS = "isy994_programs" -PYISY = None +WeatherNode = namedtuple('WeatherNode', ('status', 'name', 'uom')) -HIDDEN_STRING = DEFAULT_HIDDEN_STRING -SUPPORTED_DOMAINS = ['binary_sensor', 'cover', 'fan', 'light', 'lock', - 'sensor', 'switch'] +def _check_for_node_def(hass: HomeAssistant, node, + single_domain: str = None) -> bool: + """Check if the node matches the node_def_id for any domains. + This is only present on the 5.0 ISY firmware, and is the most reliable + way to determine a device's type. + """ + if not hasattr(node, 'node_def_id') or node.node_def_id is None: + # Node doesn't have a node_def (pre 5.0 firmware most likely) + return False -WeatherNode = namedtuple('WeatherNode', ('status', 'name', 'uom')) + node_def_id = node.node_def_id + domains = SUPPORTED_DOMAINS if not single_domain else [single_domain] + for domain in domains: + if node_def_id in NODE_FILTERS[domain]['node_def_id']: + hass.data[ISY994_NODES][domain].append(node) + return True -def filter_nodes(nodes: list, units: list=None, states: list=None) -> list: - """Filter a list of ISY nodes based on the units and states provided.""" - filtered_nodes = [] - units = units if units else [] - states = states if states else [] - for node in nodes: - match_unit = False - match_state = True - for uom in node.uom: - if uom in units: - match_unit = True - continue - elif uom not in states: - match_state = False + return False - if match_unit: - continue - if match_unit or match_state: - filtered_nodes.append(node) +def _check_for_insteon_type(hass: HomeAssistant, node, + single_domain: str = None) -> bool: + """Check if the node matches the Insteon type for any domains. + + This is for (presumably) every version of the ISY firmware, but only + works for Insteon device. "Node Server" (v5+) and Z-Wave and others will + not have a type. + """ + if not hasattr(node, 'type') or node.type is None: + # Node doesn't have a type (non-Insteon device most likely) + return False + + device_type = node.type + domains = SUPPORTED_DOMAINS if not single_domain else [single_domain] + for domain in domains: + if any([device_type.startswith(t) for t in + set(NODE_FILTERS[domain]['insteon_type'])]): - return filtered_nodes + # Hacky special-case just for FanLinc, which has a light module + # as one of its nodes. Note that this special-case is not necessary + # on ISY 5.x firmware as it uses the superior NodeDefs method + if domain == 'fan' and int(node.nid[-1]) == 1: + hass.data[ISY994_NODES]['light'].append(node) + return True + hass.data[ISY994_NODES][domain].append(node) + return True -def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None: - """Categorize the ISY994 nodes.""" - global SENSOR_NODES - global NODES - global GROUPS + return False - SENSOR_NODES = [] - NODES = [] - GROUPS = [] +def _check_for_uom_id(hass: HomeAssistant, node, + single_domain: str = None, + uom_list: list = None) -> bool: + """Check if a node's uom matches any of the domains uom filter. + + This is used for versions of the ISY firmware that report uoms as a single + ID. We can often infer what type of device it is by that ID. + """ + if not hasattr(node, 'uom') or node.uom is None: + # Node doesn't have a uom (Scenes for example) + return False + + node_uom = set(map(str.lower, node.uom)) + + if uom_list: + if node_uom.intersection(NODE_FILTERS[single_domain]['uom']): + hass.data[ISY994_NODES][single_domain].append(node) + return True + else: + domains = SUPPORTED_DOMAINS if not single_domain else [single_domain] + for domain in domains: + if node_uom.intersection(NODE_FILTERS[domain]['uom']): + hass.data[ISY994_NODES][domain].append(node) + return True + + return False + + +def _check_for_states_in_uom(hass: HomeAssistant, node, + single_domain: str = None, + states_list: list = None) -> bool: + """Check if a list of uoms matches two possible filters. + + This is for versions of the ISY firmware that report uoms as a list of all + possible "human readable" states. This filter passes if all of the possible + states fit inside the given filter. + """ + if not hasattr(node, 'uom') or node.uom is None: + # Node doesn't have a uom (Scenes for example) + return False + + node_uom = set(map(str.lower, node.uom)) + + if states_list: + if node_uom == set(states_list): + hass.data[ISY994_NODES][single_domain].append(node) + return True + else: + domains = SUPPORTED_DOMAINS if not single_domain else [single_domain] + for domain in domains: + if node_uom == set(NODE_FILTERS[domain]['states']): + hass.data[ISY994_NODES][domain].append(node) + return True + + return False + + +def _is_sensor_a_binary_sensor(hass: HomeAssistant, node) -> bool: + """Determine if the given sensor node should be a binary_sensor.""" + if _check_for_node_def(hass, node, single_domain='binary_sensor'): + return True + if _check_for_insteon_type(hass, node, single_domain='binary_sensor'): + return True + + # For the next two checks, we're providing our own set of uoms that + # represent on/off devices. This is because we can only depend on these + # checks in the context of already knowing that this is definitely a + # sensor device. + if _check_for_uom_id(hass, node, single_domain='binary_sensor', + uom_list=['2', '78']): + return True + if _check_for_states_in_uom(hass, node, single_domain='binary_sensor', + states_list=['on', 'off']): + return True + + return False + + +def _categorize_nodes(hass: HomeAssistant, nodes, ignore_identifier: str, + sensor_identifier: str)-> None: + """Sort the nodes to their proper domains.""" # pylint: disable=no-member - for (path, node) in ISY.nodes: - hidden = hidden_identifier in path or hidden_identifier in node.name - if hidden: - node.name += hidden_identifier - if sensor_identifier in path or sensor_identifier in node.name: - SENSOR_NODES.append(node) - elif isinstance(node, PYISY.Nodes.Node): - NODES.append(node) - elif isinstance(node, PYISY.Nodes.Group): - GROUPS.append(node) + for (path, node) in nodes: + ignored = ignore_identifier in path or ignore_identifier in node.name + if ignored: + # Don't import this node as a device at all + continue + from PyISY.Nodes import Group + if isinstance(node, Group): + hass.data[ISY994_NODES][SCENE_DOMAIN].append(node) + continue -def _categorize_programs() -> None: - """Categorize the ISY994 programs.""" - global PROGRAMS + if sensor_identifier in path or sensor_identifier in node.name: + # User has specified to treat this as a sensor. First we need to + # determine if it should be a binary_sensor. + if _is_sensor_a_binary_sensor(hass, node): + continue + else: + hass.data[ISY994_NODES]['sensor'].append(node) + continue - PROGRAMS = {} + # We have a bunch of different methods for determining the device type, + # each of which works with different ISY firmware versions or device + # family. The order here is important, from most reliable to least. + if _check_for_node_def(hass, node): + continue + if _check_for_insteon_type(hass, node): + continue + if _check_for_uom_id(hass, node): + continue + if _check_for_states_in_uom(hass, node): + continue - for component in SUPPORTED_DOMAINS: + +def _categorize_programs(hass: HomeAssistant, programs: dict) -> None: + """Categorize the ISY994 programs.""" + for domain in SUPPORTED_PROGRAM_DOMAINS: try: - folder = ISY.programs[KEY_MY_PROGRAMS]['HA.' + component] + folder = programs[KEY_MY_PROGRAMS]['HA.{}'.format(domain)] except KeyError: pass else: for dtype, _, node_id in folder.children: - if dtype is KEY_FOLDER: - program = folder[node_id] - try: - node = program[KEY_STATUS].leaf - assert node.dtype == 'program', 'Not a program' - except (KeyError, AssertionError): - pass + if dtype != KEY_FOLDER: + continue + entity_folder = folder[node_id] + try: + status = entity_folder[KEY_STATUS] + assert status.dtype == 'program', 'Not a program' + if domain != 'binary_sensor': + actions = entity_folder[KEY_ACTIONS] + assert actions.dtype == 'program', 'Not a program' else: - if component not in PROGRAMS: - PROGRAMS[component] = [] - PROGRAMS[component].append(program) + actions = None + except (AttributeError, KeyError, AssertionError): + _LOGGER.warning("Program entity '%s' not loaded due " + "to invalid folder structure.", + entity_folder.name) + continue + entity = (entity_folder.name, status, actions) + hass.data[ISY994_PROGRAMS][domain].append(entity) -def _categorize_weather() -> None: - """Categorize the ISY994 weather data.""" - global WEATHER_NODES - climate_attrs = dir(ISY.climate) - WEATHER_NODES = [WeatherNode(getattr(ISY.climate, attr), attr, - getattr(ISY.climate, attr + '_units')) +def _categorize_weather(hass: HomeAssistant, climate) -> None: + """Categorize the ISY994 weather data.""" + climate_attrs = dir(climate) + weather_nodes = [WeatherNode(getattr(climate, attr), + attr.replace('_', ' '), + getattr(climate, '{}_units'.format(attr))) for attr in climate_attrs - if attr + '_units' in climate_attrs] + if '{}_units'.format(attr) in climate_attrs] + hass.data[ISY994_WEATHER].extend(weather_nodes) def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ISY 994 platform.""" + hass.data[ISY994_NODES] = {} + for domain in SUPPORTED_DOMAINS: + hass.data[ISY994_NODES][domain] = [] + + hass.data[ISY994_WEATHER] = [] + + hass.data[ISY994_PROGRAMS] = {} + for domain in SUPPORTED_DOMAINS: + hass.data[ISY994_PROGRAMS][domain] = [] + isy_config = config.get(DOMAIN) user = isy_config.get(CONF_USERNAME) password = isy_config.get(CONF_PASSWORD) tls_version = isy_config.get(CONF_TLS_VER) host = urlparse(isy_config.get(CONF_HOST)) - port = host.port - addr = host.geturl() - hidden_identifier = isy_config.get( - CONF_HIDDEN_STRING, DEFAULT_HIDDEN_STRING) - sensor_identifier = isy_config.get( - CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING) - - global HIDDEN_STRING - HIDDEN_STRING = hidden_identifier + ignore_identifier = isy_config.get(CONF_IGNORE_STRING) + sensor_identifier = isy_config.get(CONF_SENSOR_STRING) + enable_climate = isy_config.get(CONF_ENABLE_CLIMATE) if host.scheme == 'http': - addr = addr.replace('http://', '') https = False + port = host.port or 80 elif host.scheme == 'https': - addr = addr.replace('https://', '') https = True + port = host.port or 443 else: _LOGGER.error("isy994 host value in configuration is invalid") return False - addr = addr.replace(':{}'.format(port), '') - import PyISY - - global PYISY - PYISY = PyISY - # Connect to ISY controller. - global ISY - ISY = PyISY.ISY(addr, port, username=user, password=password, + isy = PyISY.ISY(host.hostname, port, username=user, password=password, use_https=https, tls_ver=tls_version, log=_LOGGER) - if not ISY.connected: + if not isy.connected: return False - _categorize_nodes(hidden_identifier, sensor_identifier) + _categorize_nodes(hass, isy.nodes, ignore_identifier, sensor_identifier) + _categorize_programs(hass, isy.programs) - _categorize_programs() + if enable_climate and isy.configuration.get('Weather Information'): + _categorize_weather(hass, isy.climate) - if ISY.configuration.get('Weather Information'): - _categorize_weather() + def stop(event: object) -> None: + """Stop ISY auto updates.""" + isy.auto_update = False # Listen for HA stop to disconnect. hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop) @@ -207,57 +399,57 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: for component in SUPPORTED_DOMAINS: discovery.load_platform(hass, component, DOMAIN, {}, config) - ISY.auto_update = True + isy.auto_update = True return True -# pylint: disable=unused-argument -def stop(event: object) -> None: - """Stop ISY auto updates.""" - ISY.auto_update = False - - class ISYDevice(Entity): """Representation of an ISY994 device.""" _attrs = {} - _domain = None # type: str _name = None # type: str def __init__(self, node) -> None: """Initialize the insteon device.""" self._node = node + self._change_handler = None + self._control_handler = None + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Subscribe to the node change events.""" self._change_handler = self._node.status.subscribe( 'changed', self.on_update) + if hasattr(self._node, 'controlEvents'): + self._control_handler = self._node.controlEvents.subscribe( + self.on_control) + # pylint: disable=unused-argument def on_update(self, event: object) -> None: """Handle the update event from the ISY994 Node.""" self.schedule_update_ha_state() - @property - def domain(self) -> str: - """Get the domain of the device.""" - return self._domain + def on_control(self, event: object) -> None: + """Handle a control event from the ISY994 Node.""" + self.hass.bus.fire('isy994_control', { + 'entity_id': self.entity_id, + 'control': event + }) @property def unique_id(self) -> str: """Get the unique identifier of the device.""" # pylint: disable=protected-access - return self._node._id + if hasattr(self._node, '_id'): + return self._node._id - @property - def raw_name(self) -> str: - """Get the raw name of the device.""" - return str(self._name) \ - if self._name is not None else str(self._node.name) + return None @property def name(self) -> str: """Get the name of the device.""" - return self.raw_name.replace(HIDDEN_STRING, '').strip() \ - .replace('_', ' ') + return self._name or str(self._node.name) @property def should_poll(self) -> bool: @@ -265,11 +457,25 @@ def should_poll(self) -> bool: return False @property - def value(self) -> object: + def value(self) -> int: """Get the current value of the device.""" # pylint: disable=protected-access return self._node.status._val + def is_unknown(self) -> bool: + """Get whether or not the value of this Entity's node is unknown. + + PyISY reports unknown values as -inf + """ + return self.value == -1 * float('inf') + + @property + def state(self): + """Return the state of the ISY device.""" + if self.is_unknown(): + return None + return super().state + @property def device_state_attributes(self) -> Dict: """Get the state attributes for the device.""" @@ -278,22 +484,3 @@ def device_state_attributes(self) -> Dict: for name, val in self._node.aux_properties.items(): attr[name] = '{} {}'.format(val.get('value'), val.get('uom')) return attr - - @property - def hidden(self) -> bool: - """Get whether the device should be hidden from the UI.""" - return HIDDEN_STRING in self.raw_name - - @property - def unit_of_measurement(self) -> str: - """Get the device unit of measure.""" - return None - - def _attr_filter(self, attr: str) -> str: - """Filter the attribute.""" - # pylint: disable=no-self-use - return attr - - def update(self) -> None: - """Perform an update for the device.""" - pass diff --git a/homeassistant/components/juicenet.py b/homeassistant/components/juicenet.py index 728a4fccf8557..55567d4587901 100644 --- a/homeassistant/components/juicenet.py +++ b/homeassistant/components/juicenet.py @@ -70,5 +70,5 @@ def _token(self): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return "{}-{}".format(self.device.id(), self.type) diff --git a/homeassistant/components/keyboard_remote.py b/homeassistant/components/keyboard_remote.py index 5a81f6d2a9e3c..d737c555873b9 100644 --- a/homeassistant/components/keyboard_remote.py +++ b/homeassistant/components/keyboard_remote.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) DEVICE_DESCRIPTOR = 'device_descriptor' -DEVICE_ID_GROUP = 'Device descriptor or name' +DEVICE_ID_GROUP = 'Device description' DEVICE_NAME = 'device_name' DOMAIN = 'keyboard_remote' @@ -36,12 +36,13 @@ TYPE = 'type' CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, - vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, - vol.Optional(TYPE, default='key_up'): - vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold')), - }), + DOMAIN: + vol.All(cv.ensure_list, [vol.Schema({ + vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string, + vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string, + vol.Optional(TYPE, default='key_up'): + vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold')) + })]) }, extra=vol.ALLOW_EXTRA) @@ -49,11 +50,6 @@ def setup(hass, config): """Set up the keyboard_remote.""" config = config.get(DOMAIN) - if not config.get(DEVICE_DESCRIPTOR) and\ - not config.get(DEVICE_NAME): - _LOGGER.error("No device_descriptor or device_name found") - return - keyboard_remote = KeyboardRemote( hass, config @@ -63,7 +59,7 @@ def _start_keyboard_remote(_event): keyboard_remote.run() def _stop_keyboard_remote(_event): - keyboard_remote.stopped.set() + keyboard_remote.stop() hass.bus.listen_once( EVENT_HOMEASSISTANT_START, @@ -77,19 +73,21 @@ def _stop_keyboard_remote(_event): return True -class KeyboardRemote(threading.Thread): +class KeyboardRemoteThread(threading.Thread): """This interfaces with the inputdevice using evdev.""" - def __init__(self, hass, config): - """Construct a KeyboardRemote interface object.""" - from evdev import InputDevice, list_devices + def __init__(self, hass, device_name, device_descriptor, key_value): + """Construct a thread listening for events on one device.""" + self.hass = hass + self.device_name = device_name + self.device_descriptor = device_descriptor + self.key_value = key_value - self.device_descriptor = config.get(DEVICE_DESCRIPTOR) - self.device_name = config.get(DEVICE_NAME) if self.device_descriptor: self.device_id = self.device_descriptor else: self.device_id = self.device_name + self.dev = self._get_keyboard_device() if self.dev is not None: _LOGGER.debug("Keyboard connected, %s", self.device_id) @@ -103,6 +101,7 @@ def __init__(self, hass, config): id_folder = '/dev/input/by-id/' if os.path.isdir(id_folder): + from evdev import InputDevice, list_devices device_names = [InputDevice(file_name).name for file_name in list_devices()] _LOGGER.debug( @@ -116,7 +115,6 @@ def __init__(self, hass, config): threading.Thread.__init__(self) self.stopped = threading.Event() self.hass = hass - self.key_value = KEY_VALUE.get(config.get(TYPE, 'key_up')) def _get_keyboard_device(self): """Get the keyboard device.""" @@ -145,7 +143,7 @@ def run(self): while not self.stopped.isSet(): # Sleeps to ease load on processor - time.sleep(.1) + time.sleep(.05) if self.dev is None: self.dev = self._get_keyboard_device() @@ -178,3 +176,32 @@ def run(self): KEYBOARD_REMOTE_COMMAND_RECEIVED, {KEY_CODE: event.code} ) + + +class KeyboardRemote(object): + """Sets up one thread per device.""" + + def __init__(self, hass, config): + """Construct a KeyboardRemote interface object.""" + self.threads = [] + for dev_block in config: + device_descriptor = dev_block.get(DEVICE_DESCRIPTOR) + device_name = dev_block.get(DEVICE_NAME) + key_value = KEY_VALUE.get(dev_block.get(TYPE, 'key_up')) + + if device_descriptor is not None\ + or device_name is not None: + thread = KeyboardRemoteThread(hass, device_name, + device_descriptor, + key_value) + self.threads.append(thread) + + def run(self): + """Run all event listener threads.""" + for thread in self.threads: + thread.start() + + def stop(self): + """Stop all event listener threads.""" + for thread in self.threads: + thread.stopped.set() diff --git a/homeassistant/components/kira.py b/homeassistant/components/kira.py index 98d1228d541d1..3a5ee25f05ebd 100644 --- a/homeassistant/components/kira.py +++ b/homeassistant/components/kira.py @@ -1,26 +1,23 @@ -"""KIRA interface to receive UDP packets from an IR-IP bridge.""" -# pylint: disable=import-error +""" +KIRA interface to receive UDP packets from an IR-IP bridge. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/kira/ +""" import logging import os -import yaml import voluptuous as vol from voluptuous.error import Error as VoluptuousError +import yaml +from homeassistant.const import ( + CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_PORT, CONF_SENSORS, CONF_TYPE, + EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_DEVICE, - CONF_HOST, - CONF_NAME, - CONF_PORT, - CONF_SENSORS, - CONF_TYPE, - EVENT_HOMEASSISTANT_STOP, - STATE_UNKNOWN) - -REQUIREMENTS = ["pykira==0.1.1"] +REQUIREMENTS = ['pykira==0.1.1'] DOMAIN = 'kira' @@ -67,7 +64,7 @@ def load_codes(path): - """Load Kira codes from specified file.""" + """Load KIRA codes from specified file.""" codes = [] if os.path.exists(path): with open(path) as code_file: @@ -77,7 +74,7 @@ def load_codes(path): codes.append(CODE_SCHEMA(code)) except VoluptuousError as exception: # keep going - _LOGGER.warning('Kira Code Invalid Data: %s', exception) + _LOGGER.warning("KIRA code invalid data: %s", exception) else: with open(path, 'w') as code_file: code_file.write('') @@ -85,7 +82,7 @@ def load_codes(path): def setup(hass, config): - """Setup KIRA capability.""" + """Set up the KIRA component.""" import pykira sensors = config.get(DOMAIN, {}).get(CONF_SENSORS, []) @@ -99,10 +96,10 @@ def setup(hass, config): hass.data[DOMAIN] = { CONF_SENSOR: {}, CONF_REMOTE: {}, - } + } def load_module(platform, idx, module_conf): - """Set up Kira module and load platform.""" + """Set up the KIRA module and load platform.""" # note: module_name is not the HA device name. it's just a unique name # to ensure the component and platform can share information module_name = ("%s_%d" % (DOMAIN, idx)) if idx else DOMAIN @@ -133,6 +130,7 @@ def load_module(platform, idx, module_conf): load_module(CONF_REMOTE, idx, module_conf) def _stop_kira(_event): + """Stop the KIRA receiver.""" for receiver in hass.data[DOMAIN][CONF_SENSOR].values(): receiver.stop() _LOGGER.info("Terminated receivers") diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index a5015ff94546f..61f8ca90137f5 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -1,22 +1,24 @@ """ - Connects to KNX platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/knx/ - """ + import logging -import asyncio import voluptuous as vol +from homeassistant.const import ( + CONF_ENTITY_ID, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.const import EVENT_HOMEASSISTANT_STOP, \ - CONF_HOST, CONF_PORT +from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.script import Script +REQUIREMENTS = ['xknx==0.8.5'] + DOMAIN = "knx" DATA_KNX = "data_knx" CONF_KNX_CONFIG = "config_file" @@ -26,6 +28,10 @@ CONF_KNX_LOCAL_IP = "local_ip" CONF_KNX_FIRE_EVENT = "fire_event" CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter" +CONF_KNX_STATE_UPDATER = "state_updater" +CONF_KNX_EXPOSE = "expose" +CONF_KNX_EXPOSE_TYPE = "type" +CONF_KNX_EXPOSE_ADDRESS = "address" SERVICE_KNX_SEND = "send" SERVICE_KNX_ATTR_ADDRESS = "address" @@ -35,18 +41,22 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['xknx==0.7.13'] - TUNNELING_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT): cv.port, vol.Required(CONF_KNX_LOCAL_IP): cv.string, + vol.Optional(CONF_PORT): cv.port, }) ROUTING_SCHEMA = vol.Schema({ vol.Required(CONF_KNX_LOCAL_IP): cv.string, }) +EXPOSE_SCHEMA = vol.Schema({ + vol.Required(CONF_KNX_EXPOSE_TYPE): cv.string, + vol.Optional(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_KNX_EXPOSE_ADDRESS): cv.string, +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_KNX_CONFIG): cv.string, @@ -56,9 +66,12 @@ vol.Inclusive(CONF_KNX_FIRE_EVENT, 'fire_ev'): cv.boolean, vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_KNX_STATE_UPDATER, default=True): cv.boolean, + vol.Optional(CONF_KNX_EXPOSE): vol.All( cv.ensure_list, - [cv.string]) + [EXPOSE_SCHEMA]), }) }, extra=vol.ALLOW_EXTRA) @@ -69,17 +82,20 @@ }) -@asyncio.coroutine -def async_setup(hass, config): - """Set up knx component.""" +async def async_setup(hass, config): + """Set up the KNX component.""" from xknx.exceptions import XKNXException try: hass.data[DATA_KNX] = KNXModule(hass, config) - yield from hass.data[DATA_KNX].start() + hass.data[DATA_KNX].async_create_exposures() + await hass.data[DATA_KNX].start() except XKNXException as ex: - _LOGGER.exception("Can't connect to KNX interface: %s", ex) - return False + _LOGGER.warning("Can't connect to KNX interface: %s", ex) + hass.components.persistent_notification.async_create( + "Can't connect to KNX interface:
    " + "{0}".format(ex), + title="KNX") for component, discovery_type in ( ('switch', 'Switch'), @@ -88,6 +104,7 @@ def async_setup(hass, config): ('light', 'Light'), ('sensor', 'Sensor'), ('binary_sensor', 'BinarySensor'), + ('scene', 'Scene'), ('notify', 'Notification')): found_devices = _get_devices(hass, discovery_type) hass.async_add_job( @@ -104,6 +121,7 @@ def async_setup(hass, config): def _get_devices(hass, discovery_type): + """Get the KNX devices.""" return list( map(lambda device: device.name, filter( @@ -115,34 +133,31 @@ class KNXModule(object): """Representation of KNX Object.""" def __init__(self, hass, config): - """Initialization of KNXModule.""" + """Initialize of KNX module.""" self.hass = hass self.config = config - self.initialized = False + self.connected = False self.init_xknx() self.register_callbacks() + self.exposures = [] def init_xknx(self): - """Initialization of KNX object.""" + """Initialize of KNX object.""" from xknx import XKNX - self.xknx = XKNX( - config=self.config_file(), - loop=self.hass.loop) + self.xknx = XKNX(config=self.config_file(), loop=self.hass.loop) - @asyncio.coroutine - def start(self): + async def start(self): """Start KNX object. Connect to tunneling or Routing device.""" connection_config = self.connection_config() - yield from self.xknx.start( - state_updater=True, + await self.xknx.start( + state_updater=self.config[DOMAIN][CONF_KNX_STATE_UPDATER], connection_config=connection_config) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) - self.initialized = True + self.connected = True - @asyncio.coroutine - def stop(self, event): + async def stop(self, event): """Stop KNX object. Disconnect from tunneling or Routing device.""" - yield from self.xknx.stop() + await self.xknx.stop() def config_file(self): """Resolve and return the full path of xknx.yaml if configured.""" @@ -183,10 +198,8 @@ def connection_config_tunneling(self): if gateway_port is None: gateway_port = DEFAULT_MCAST_PORT return ConnectionConfig( - connection_type=ConnectionType.TUNNELING, - gateway_ip=gateway_ip, - gateway_port=gateway_port, - local_ip=local_ip) + connection_type=ConnectionType.TUNNELING, gateway_ip=gateway_ip, + gateway_port=gateway_port, local_ip=local_ip) def connection_config_auto(self): """Return the connection_config if auto is configured.""" @@ -205,20 +218,38 @@ def register_callbacks(self): self.xknx.telegram_queue.register_telegram_received_cb( self.telegram_received_cb, address_filters) - @asyncio.coroutine - def telegram_received_cb(self, telegram): - """Callback invoked after a KNX telegram was received.""" + @callback + def async_create_exposures(self): + """Create exposures.""" + if CONF_KNX_EXPOSE not in self.config[DOMAIN]: + return + for to_expose in self.config[DOMAIN][CONF_KNX_EXPOSE]: + expose_type = to_expose.get(CONF_KNX_EXPOSE_TYPE) + entity_id = to_expose.get(CONF_ENTITY_ID) + address = to_expose.get(CONF_KNX_EXPOSE_ADDRESS) + if expose_type in ['time', 'date', 'datetime']: + exposure = KNXExposeTime( + self.xknx, expose_type, address) + exposure.async_register() + self.exposures.append(exposure) + else: + exposure = KNXExposeSensor( + self.hass, self.xknx, expose_type, entity_id, address) + exposure.async_register() + self.exposures.append(exposure) + + async def telegram_received_cb(self, telegram): + """Call invoked after a KNX telegram was received.""" self.hass.bus.fire('knx_event', { - 'address': telegram.group_address.str(), + 'address': str(telegram.group_address), 'data': telegram.payload.value }) # False signals XKNX to proceed with processing telegrams. return False - @asyncio.coroutine - def service_send_to_knx_bus(self, call): - """Service for sending an arbitray KNX message to the KNX bus.""" - from xknx.knx import Telegram, Address, DPTBinary, DPTArray + async def service_send_to_knx_bus(self, call): + """Service for sending an arbitrary KNX message to the KNX bus.""" + from xknx.knx import Telegram, GroupAddress, DPTBinary, DPTArray attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD) attr_address = call.data.get(SERVICE_KNX_ATTR_ADDRESS) @@ -228,12 +259,12 @@ def calculate_payload(attr_payload): return DPTBinary(attr_payload) return DPTArray(attr_payload) payload = calculate_payload(attr_payload) - address = Address(attr_address) + address = GroupAddress(attr_address) telegram = Telegram() telegram.payload = payload telegram.group_address = address - yield from self.xknx.telegrams.put(telegram) + await self.xknx.telegrams.put(telegram) class KNXAutomation(): @@ -248,8 +279,62 @@ def __init__(self, hass, device, hook, action, counter=1): import xknx self.action = xknx.devices.ActionCallback( - hass.data[DATA_KNX].xknx, - self.script.async_run, - hook=hook, - counter=counter) + hass.data[DATA_KNX].xknx, self.script.async_run, + hook=hook, counter=counter) device.actions.append(self.action) + + +class KNXExposeTime(object): + """Object to Expose Time/Date object to KNX bus.""" + + def __init__(self, xknx, expose_type, address): + """Initialize of Expose class.""" + self.xknx = xknx + self.type = expose_type + self.address = address + self.device = None + + @callback + def async_register(self): + """Register listener.""" + from xknx.devices import DateTime, DateTimeBroadcastType + broadcast_type_string = self.type.upper() + broadcast_type = DateTimeBroadcastType[broadcast_type_string] + self.device = DateTime( + self.xknx, + 'Time', + broadcast_type=broadcast_type, + group_address=self.address) + self.xknx.devices.add(self.device) + + +class KNXExposeSensor(object): + """Object to Expose HASS entity to KNX bus.""" + + def __init__(self, hass, xknx, expose_type, entity_id, address): + """Initialize of Expose class.""" + self.hass = hass + self.xknx = xknx + self.type = expose_type + self.entity_id = entity_id + self.address = address + self.device = None + + @callback + def async_register(self): + """Register listener.""" + from xknx.devices import ExposeSensor + self.device = ExposeSensor( + self.xknx, + name=self.entity_id, + group_address=self.address, + value_type=self.type) + self.xknx.devices.add(self.device) + async_track_state_change( + self.hass, self.entity_id, self._async_entity_changed) + + async def _async_entity_changed(self, entity_id, old_state, new_state): + """Callback after entity changed.""" + if new_state is None: + return + await self.device.set(float(new_state.state)) diff --git a/homeassistant/components/konnected.py b/homeassistant/components/konnected.py new file mode 100644 index 0000000000000..8c5578f10e470 --- /dev/null +++ b/homeassistant/components/konnected.py @@ -0,0 +1,315 @@ +""" +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_STATE) +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]+}') + + +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} + + async def async_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) + await device.async_setup() + + discovery.async_listen( + hass, + SERVICE_KONNECTED, + async_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) + + async def async_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() + await self.async_sync_device_config() + await discovery.async_load_platform( + self.hass, 'binary_sensor', + DOMAIN, {'device_id': self.device_id}) + await discovery.async_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].keys()] + + 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()] + + async def async_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 = pin_data.get('entity') + if entity is None: + return self.json_message('uninitialized sensor/actuator', + status_code=HTTP_INTERNAL_SERVER_ERROR) + + await entity.async_set_state(state) + return self.json_message('ok') diff --git a/homeassistant/components/lametric.py b/homeassistant/components/lametric.py index b11d874127fbb..49b4f73ea17e3 100644 --- a/homeassistant/components/lametric.py +++ b/homeassistant/components/lametric.py @@ -38,46 +38,26 @@ def setup(hass, config): conf = config[DOMAIN] hlmn = HassLaMetricManager(client_id=conf[CONF_CLIENT_ID], client_secret=conf[CONF_CLIENT_SECRET]) - devices = hlmn.manager().get_devices() + devices = hlmn.manager.get_devices() + if not devices: + _LOGGER.error("No LaMetric devices found") + return False - found = False hass.data[DOMAIN] = hlmn for dev in devices: _LOGGER.debug("Discovered LaMetric device: %s", dev) - found = True - return found + return True class HassLaMetricManager(): - """ - A class that encapsulated requests to the LaMetric manager. - - As the original class does not have a re-connect feature that is needed - for applications running for a long time as the OAuth tokens expire. This - class implements this reconnect() feature. - """ + """A class that encapsulated requests to the LaMetric manager.""" def __init__(self, client_id, client_secret): """Initialize HassLaMetricManager and connect to LaMetric.""" from lmnotify import LaMetricManager _LOGGER.debug("Connecting to LaMetric") - self.lmn = LaMetricManager(client_id, client_secret) + self.manager = LaMetricManager(client_id, client_secret) self._client_id = client_id self._client_secret = client_secret - - def reconnect(self): - """ - Reconnect to LaMetric. - - This is usually necessary when the OAuth token is expired. - """ - from lmnotify import LaMetricManager - _LOGGER.debug("Reconnecting to LaMetric") - self.lmn = LaMetricManager(self._client_id, - self._client_secret) - - def manager(self): - """Return the global LaMetricManager instance.""" - return self.lmn diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 4e9fbbf81ab86..30a1a800a4498 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -5,44 +5,43 @@ https://home-assistant.io/components/light/ """ import asyncio +import csv from datetime import timedelta import logging import os -import csv import voluptuous as vol -from homeassistant.core import callback -from homeassistant.loader import bind_hass -from homeassistant.components import group -from homeassistant.config import load_yaml_config_file +from homeassistant.components.group import \ + ENTITY_ID_FORMAT as GROUP_ENTITY_ID_FORMAT from homeassistant.const import ( - STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, - ATTR_ENTITY_ID) + ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_ON) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.restore_state import async_restore_state +from homeassistant.helpers import intent +from homeassistant.loader import bind_hass import homeassistant.util.color as color_util -DOMAIN = "light" +DOMAIN = 'light' DEPENDENCIES = ['group'] SCAN_INTERVAL = timedelta(seconds=30) GROUP_NAME_ALL_LIGHTS = 'all lights' -ENTITY_ID_ALL_LIGHTS = group.ENTITY_ID_FORMAT.format('all_lights') +ENTITY_ID_ALL_LIGHTS = GROUP_ENTITY_ID_FORMAT.format('all_lights') -ENTITY_ID_FORMAT = DOMAIN + ".{}" +ENTITY_ID_FORMAT = DOMAIN + '.{}' # Bitfield of features supported by the light entity 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 @@ # 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" @@ -88,8 +88,7 @@ 'color_temp': ATTR_COLOR_TEMP, 'min_mireds': ATTR_MIN_MIREDS, 'max_mireds': ATTR_MAX_MIREDS, - 'rgb_color': ATTR_RGB_COLOR, - 'xy_color': ATTR_XY_COLOR, + 'hs_color': ATTR_HS_COLOR, 'white_value': ATTR_WHITE_VALUE, 'effect_list': ATTR_EFFECT_LIST, 'effect': ATTR_EFFECT, @@ -113,6 +112,11 @@ 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): @@ -137,15 +141,9 @@ vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte)) ) -_LOGGER = logging.getLogger(__name__) - +INTENT_SET = 'HassLightSet' -def extract_info(state): - """Extract light parameters from a state object.""" - params = {key: state.attributes[key] for key in PROP_TO_ATTR - if key in state.attributes} - params['is_on'] = state.state == STATE_ON - return params +_LOGGER = logging.getLogger(__name__) @bind_hass @@ -157,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) @@ -171,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 [ @@ -183,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), @@ -216,8 +216,9 @@ def async_turn_off(hass, entity_id=None, transition=None): DOMAIN, SERVICE_TURN_OFF, data)) +@callback @bind_hass -def toggle(hass, entity_id=None, transition=None): +def async_toggle(hass, entity_id=None, transition=None): """Toggle all or specified light.""" data = { key: value for key, value in [ @@ -226,11 +227,18 @@ def toggle(hass, entity_id=None, transition=None): ] if value is not None } - hass.services.call(DOMAIN, SERVICE_TOGGLE, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, data)) + + +@bind_hass +def toggle(hass, entity_id=None, transition=None): + """Toggle all or specified light.""" + hass.add_job(async_toggle, hass, entity_id, transition) def preprocess_turn_on_alternatives(params): - """Processing extra data for turn light on request.""" + """Process extra data for turn light on request.""" profile = Profiles.get(params.pop(ATTR_PROFILE, None)) if profile is not None: params.setdefault(ATTR_XY_COLOR, profile[:2]) @@ -238,7 +246,12 @@ def preprocess_turn_on_alternatives(params): color_name = params.pop(ATTR_COLOR_NAME, None) if color_name is not None: - params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + try: + params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + except ValueError: + _LOGGER.warning('Got unknown color %s, falling back to white', + color_name) + params[ATTR_RGB_COLOR] = (255, 255, 255) kelvin = params.pop(ATTR_KELVIN, None) if kelvin is not None: @@ -249,22 +262,89 @@ 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) -@asyncio.coroutine -def async_setup(hass, config): - """Expose light control via statemachine and services.""" - component = EntityComponent( + 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.""" + + intent_type = INTENT_SET + slot_schema = { + vol.Required('name'): cv.string, + vol.Optional('color'): color_util.color_name_to_rgb, + vol.Optional('brightness'): vol.All(vol.Coerce(int), vol.Range(0, 100)) + } + + async def async_handle(self, intent_obj): + """Handle the hass intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + state = hass.helpers.intent.async_match_state( + slots['name']['value'], + [state for state in hass.states.async_all() + if state.domain == DOMAIN]) + + service_data = { + ATTR_ENTITY_ID: state.entity_id, + } + speech_parts = [] + + if 'color' in slots: + intent.async_test_feature( + 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. + speech_parts.append('the color {}'.format( + intent_obj.slots['color']['value'])) + + if 'brightness' in slots: + intent.async_test_feature( + state, SUPPORT_BRIGHTNESS, 'changing brightness') + service_data[ATTR_BRIGHTNESS_PCT] = slots['brightness']['value'] + speech_parts.append('{}% brightness'.format( + slots['brightness']['value'])) + + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, service_data) + + response = intent_obj.create_response() + + if not speech_parts: # No attributes changed + speech = 'Turned on {}'.format(state.name) + else: + parts = ['Changed {} to'.format(state.name)] + for index, part in enumerate(speech_parts): + if index == 0: + parts.append(' {}'.format(part)) + elif index != len(speech_parts) - 1: + parts.append(', {}'.format(part)) + else: + parts.append(' and {}'.format(part)) + speech = ''.join(parts) + + response.async_set_speech(speech) + return response + + +async def async_setup(hass, config): + """Expose light control via state machine and services.""" + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LIGHTS) - yield from component.async_setup(config) + await component.async_setup(config) # load profiles from files - profiles_valid = yield from Profiles.load_profiles(hass) + profiles_valid = await Profiles.load_profiles(hass) if not profiles_valid: return False - @asyncio.coroutine - def async_handle_light_service(service): - """Hande a turn light on or off service call.""" + async def async_handle_light_service(service): + """Handle a turn light on or off service call.""" # Get the validated data params = service.data.copy() @@ -274,58 +354,57 @@ def async_handle_light_service(service): preprocess_turn_on_alternatives(params) + update_tasks = [] for light in target_lights: if service.service == SERVICE_TURN_ON: - yield from light.async_turn_on(**params) + await light.async_turn_on(**params) elif service.service == SERVICE_TURN_OFF: - yield from light.async_turn_off(**params) + await light.async_turn_off(**params) else: - yield from light.async_toggle(**params) + await light.async_toggle(**params) - update_tasks = [] - - for light in target_lights: if not light.should_poll: continue - - update_coro = hass.async_add_job( - light.async_update_ha_state(True)) - if hasattr(light, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(light.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) # Listen for light on and light off service calls. - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_handle_light_service, - descriptions.get(SERVICE_TURN_ON), schema=LIGHT_TURN_ON_SCHEMA) + schema=LIGHT_TURN_ON_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_handle_light_service, - descriptions.get(SERVICE_TURN_OFF), schema=LIGHT_TURN_OFF_SCHEMA) + schema=LIGHT_TURN_OFF_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TOGGLE, async_handle_light_service, - descriptions.get(SERVICE_TOGGLE), schema=LIGHT_TOGGLE_SCHEMA) + schema=LIGHT_TOGGLE_SCHEMA) + + hass.helpers.intent.async_register(SetIntentHandler()) 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.""" _all = None @classmethod - @asyncio.coroutine - def load_profiles(cls, hass): + async def load_profiles(cls, hass): """Load and cache profiles.""" def load_profile_data(hass): """Load built-in profiles and custom profiles.""" @@ -355,7 +434,7 @@ def load_profile_data(hass): return None return profiles - cls._all = yield from hass.async_add_job(load_profile_data, hass) + cls._all = await hass.async_add_job(load_profile_data, hass) return cls._all is not None @classmethod @@ -375,13 +454,8 @@ def brightness(self): 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 @@ -393,12 +467,14 @@ def color_temp(self): 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 @@ -421,17 +497,26 @@ def state_attributes(self): """Return optional state attributes.""" data = {} + if self.supported_features & SUPPORT_COLOR_TEMP: + data[ATTR_MIN_MIREDS] = self.min_mireds + data[ATTR_MAX_MIREDS] = self.max_mireds + if self.is_on: for prop, attr in PROP_TO_ATTR.items(): value = getattr(self, prop) 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 @@ -439,9 +524,3 @@ def state_attributes(self): def supported_features(self): """Flag supported features.""" return 0 - - @asyncio.coroutine - def async_added_to_hass(self): - """Component added, restore_state using platforms.""" - if hasattr(self, 'async_restore_state'): - yield from async_restore_state(self, extract_info) diff --git a/homeassistant/components/light/abode.py b/homeassistant/components/light/abode.py new file mode 100644 index 0000000000000..8b7e09d86bceb --- /dev/null +++ b/homeassistant/components/light/abode.py @@ -0,0 +1,94 @@ +""" +This component provides HA light support for Abode Security System. + +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_HS_COLOR, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light) +import homeassistant.util.color as color_util + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode light devices.""" + import abodepy.helpers.constants as CONST + + data = hass.data[ABODE_DOMAIN] + + device_types = [CONST.TYPE_LIGHT, CONST.TYPE_SWITCH] + + devices = [] + + # Get all regular lights that are not excluded or switches marked as lights + for device in data.abode.get_devices(generic_type=device_types): + if data.is_excluded(device) or not data.is_light(device): + continue + + devices.append(AbodeLight(data, device)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeLight(AbodeDevice, Light): + """Representation of an Abode light.""" + + def turn_on(self, **kwargs): + """Turn on the light.""" + if (ATTR_HS_COLOR in kwargs and + self._device.is_dimmable and self._device.has_color): + 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() + + def turn_off(self, **kwargs): + """Turn off the light.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on + + @property + def brightness(self): + """Return the brightness of the light.""" + if self._device.is_dimmable and self._device.has_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 hs_color(self): + """Return the color of the light.""" + if self._device.is_dimmable and self._device.has_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_COLOR + elif self._device.is_dimmable: + return SUPPORT_BRIGHTNESS + + return 0 diff --git a/homeassistant/components/light/ads.py b/homeassistant/components/light/ads.py new file mode 100644 index 0000000000000..41709a4692b6d --- /dev/null +++ b/homeassistant/components/light/ads.py @@ -0,0 +1,117 @@ +""" +Support for ADS light sources. + +For more details about this platform, please refer to the documentation. +https://home-assistant.io/components/light.ads/ + +""" +import asyncio +import logging +import voluptuous as vol +from homeassistant.components.light import Light, ATTR_BRIGHTNESS, \ + SUPPORT_BRIGHTNESS, PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.components.ads import DATA_ADS, CONF_ADS_VAR, \ + CONF_ADS_VAR_BRIGHTNESS +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['ads'] +DEFAULT_NAME = 'ADS Light' +CONF_ADSVAR_BRIGHTNESS = 'adsvar_brightness' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADS_VAR): cv.string, + vol.Optional(CONF_ADS_VAR_BRIGHTNESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the light platform for ADS.""" + ads_hub = hass.data.get(DATA_ADS) + + ads_var_enable = config.get(CONF_ADS_VAR) + ads_var_brightness = config.get(CONF_ADS_VAR_BRIGHTNESS) + name = config.get(CONF_NAME) + + add_devices([AdsLight(ads_hub, ads_var_enable, ads_var_brightness, + name)], True) + + +class AdsLight(Light): + """Representation of ADS light.""" + + def __init__(self, ads_hub, ads_var_enable, ads_var_brightness, name): + """Initialize AdsLight entity.""" + self._ads_hub = ads_hub + self._on_state = False + self._brightness = None + self._name = name + self.ads_var_enable = ads_var_enable + self.ads_var_brightness = ads_var_brightness + + @asyncio.coroutine + def async_added_to_hass(self): + """Register device notification.""" + def update_on_state(name, value): + """Handle device notifications for state.""" + _LOGGER.debug('Variable %s changed its value to %d', name, value) + self._on_state = value + self.schedule_update_ha_state() + + def update_brightness(name, value): + """Handle device notification for brightness.""" + _LOGGER.debug('Variable %s changed its value to %d', name, value) + self._brightness = value + self.schedule_update_ha_state() + + self.hass.async_add_job( + self._ads_hub.add_device_notification, + self.ads_var_enable, self._ads_hub.PLCTYPE_BOOL, update_on_state + ) + self.hass.async_add_job( + self._ads_hub.add_device_notification, + self.ads_var_brightness, self._ads_hub.PLCTYPE_INT, + update_brightness + ) + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def brightness(self): + """Return the brightness of the light (0..255).""" + return self._brightness + + @property + def is_on(self): + """Return if light is on.""" + return self._on_state + + @property + def should_poll(self): + """Return False because entity pushes its state to HA.""" + return False + + @property + def supported_features(self): + """Flag supported features.""" + if self.ads_var_brightness is not None: + return SUPPORT_BRIGHTNESS + + def turn_on(self, **kwargs): + """Turn the light on or set a specific dimmer value.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + self._ads_hub.write_by_name(self.ads_var_enable, True, + self._ads_hub.PLCTYPE_BOOL) + + if self.ads_var_brightness is not None and brightness is not None: + self._ads_hub.write_by_name(self.ads_var_brightness, brightness, + self._ads_hub.PLCTYPE_UINT) + + def turn_off(self, **kwargs): + """Turn the light off.""" + self._ads_hub.write_by_name(self.ads_var_enable, False, + self._ads_hub.PLCTYPE_BOOL) diff --git a/homeassistant/components/light/avion.py b/homeassistant/components/light/avion.py index f214d47fa1b5b..b4b9f4e777567 100644 --- a/homeassistant/components/light/avion.py +++ b/homeassistant/components/light/avion.py @@ -37,7 +37,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Avion switch.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import avion lights = [] @@ -70,7 +70,7 @@ class AvionLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import avion self._name = device['name'] @@ -83,7 +83,7 @@ def __init__(self, device): @property def unique_id(self): """Return the ID of this light.""" - return "{}.{}".format(self.__class__, self._address) + return self._address @property def name(self): @@ -117,7 +117,7 @@ def assumed_state(self): def set_state(self, brightness): """Set the state of this lamp to the provided brightness.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import avion # Bluetooth LE is unreliable, and the connection may drop at any diff --git a/homeassistant/components/light/blinksticklight.py b/homeassistant/components/light/blinksticklight.py index d6a6ef465a8f1..18a6b4ae266d9 100644 --- a/homeassistant/components/light/blinksticklight.py +++ b/homeassistant/components/light/blinksticklight.py @@ -9,9 +9,11 @@ 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 @@ 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 @@ def __init__(self, stick, name): 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 @@ def name(self): 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 @@ def supported_features(self): 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 e2bef31089f72..97edd7c54d254 100644 --- a/homeassistant/components/light/blinkt.py +++ b/homeassistant/components/light/blinkt.py @@ -10,15 +10,16 @@ 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' @@ -29,7 +30,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Blinkt Light platform.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import blinkt # ensure that the lights are off when exiting @@ -37,22 +38,25 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) - add_devices([BlinktLight(blinkt, name)]) + add_devices([ + BlinktLight(blinkt, name, index) for index in range(blinkt.NUM_PIXELS) + ]) class BlinktLight(Light): """Representation of a Blinkt! Light.""" - def __init__(self, blinkt, name): + def __init__(self, blinkt, name, index): """Initialize a Blinkt Light. Default brightness and white color. """ self._blinkt = blinkt - self._name = name + self._name = "{}_{}".format(name, index) + 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): @@ -68,12 +72,9 @@ def brightness(self): 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): @@ -97,16 +98,18 @@ def assumed_state(self) -> bool: 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) - self._blinkt.set_all(self._rgb_color[0], - self._rgb_color[1], - self._rgb_color[2], - percent_bright) + rgb_color = color_util.color_hs_to_RGB(*self._hs_color) + self._blinkt.set_pixel(self._index, + rgb_color[0], + rgb_color[1], + rgb_color[2], + percent_bright) self._blinkt.show() @@ -115,7 +118,7 @@ def turn_on(self, **kwargs): def turn_off(self, **kwargs): """Instruct the light to turn off.""" - self._blinkt.set_brightness(0) + self._blinkt.set_pixel(self._index, 0, 0, 0, 0) self._blinkt.show() self._is_on = False self.schedule_update_ha_state() diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py new file mode 100644 index 0000000000000..916e60c00b1b9 --- /dev/null +++ b/homeassistant/components/light/deconz.py @@ -0,0 +1,182 @@ +""" +Support for deCONZ light. + +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, DATA_DECONZ_UNSUB) +from homeassistant.components.light import ( + 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.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): + """Old way of setting up deCONZ lights and group.""" + pass + + +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)) + + @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): + """Representation of a deCONZ light.""" + + def __init__(self, light): + """Set up light and add update callback to get data from websocket.""" + self._light = light + + self._features = SUPPORT_BRIGHTNESS + self._features |= SUPPORT_FLASH + self._features |= SUPPORT_TRANSITION + + if self._light.ct is not None: + self._features |= SUPPORT_COLOR_TEMP + + if self._light.xy is not None: + self._features |= SUPPORT_COLOR + + if self._light.effect is not None: + self._features |= SUPPORT_EFFECT + + async def async_added_to_hass(self): + """Subscribe to lights events.""" + self._light.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._light.deconz_id + + @callback + def async_update_callback(self, reason): + """Update the light's state.""" + self.async_schedule_update_ha_state() + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._light.brightness + + @property + def effect_list(self): + """Return the list of supported effects.""" + return [EFFECT_COLORLOOP] + + @property + def color_temp(self): + """Return the CT color value.""" + return self._light.ct + + @property + def xy_color(self): + """Return the XY color value.""" + return self._light.xy + + @property + def is_on(self): + """Return true if light is on.""" + return self._light.state + + @property + def name(self): + """Return the name of the light.""" + return self._light.name + + @property + def unique_id(self): + """Return a unique identifier for this light.""" + return self._light.uniqueid + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + @property + def available(self): + """Return True if light is available.""" + return self._light.reachable + + @property + def should_poll(self): + """No polling needed.""" + return False + + async def async_turn_on(self, **kwargs): + """Turn on light.""" + data = {'on': True} + + if ATTR_COLOR_TEMP in kwargs: + data['ct'] = kwargs[ATTR_COLOR_TEMP] + + 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] + + if ATTR_TRANSITION in kwargs: + data['transitiontime'] = int(kwargs[ATTR_TRANSITION]) * 10 + + if ATTR_FLASH in kwargs: + if kwargs[ATTR_FLASH] == FLASH_SHORT: + data['alert'] = 'select' + del data['on'] + elif kwargs[ATTR_FLASH] == FLASH_LONG: + data['alert'] = 'lselect' + del data['on'] + + if ATTR_EFFECT in kwargs: + if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: + data['effect'] = 'colorloop' + else: + data['effect'] = 'none' + + await self._light.async_set_state(data) + + async def async_turn_off(self, **kwargs): + """Turn off light.""" + data = {'on': False} + + if ATTR_TRANSITION in kwargs: + data = {'bri': 0} + data['transitiontime'] = int(kwargs[ATTR_TRANSITION]) * 10 + + if ATTR_FLASH in kwargs: + if kwargs[ATTR_FLASH] == FLASH_SHORT: + data['alert'] = 'select' + del data['on'] + elif kwargs[ATTR_FLASH] == FLASH_LONG: + data['alert'] = 'lselect' + del data['on'] + + await self._light.async_set_state(data) diff --git a/homeassistant/components/light/decora.py b/homeassistant/components/light/decora.py index 17cc741c59317..c7478b435ee3e 100644 --- a/homeassistant/components/light/decora.py +++ b/homeassistant/components/light/decora.py @@ -16,7 +16,7 @@ PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['decora==0.6', 'bluepy==1.1.1'] +REQUIREMENTS = ['decora==0.6', 'bluepy==1.1.4'] _LOGGER = logging.getLogger(__name__) @@ -35,9 +35,9 @@ def retry(method): """Retry bluetooth commands.""" @wraps(method) - def wrapper_retry(device, *args, **kwds): + def wrapper_retry(device, *args, **kwargs): """Try send command and retry on error.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import decora import bluepy @@ -46,7 +46,7 @@ def wrapper_retry(device, *args, **kwds): if time.monotonic() - initial >= 10: return None try: - return method(device, *args, **kwds) + return method(device, *args, **kwargs) except (decora.decoraException, AttributeError, bluepy.btle.BTLEException): _LOGGER.warning("Decora connect error for device %s. " @@ -75,7 +75,7 @@ class DecoraLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import decora self._name = device['name'] @@ -88,7 +88,7 @@ def __init__(self, device): @property def unique_id(self): """Return the ID of this light.""" - return "{}.{}".format(self.__class__, self._address) + return self._address @property def name(self): diff --git a/homeassistant/components/light/decora_wifi.py b/homeassistant/components/light/decora_wifi.py index 971ad21e84baa..111d39f20190a 100644 --- a/homeassistant/components/light/decora_wifi.py +++ b/homeassistant/components/light/decora_wifi.py @@ -36,7 +36,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Decora WiFi platform.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member, no-name-in-module from decora_wifi import DecoraWiFiSession from decora_wifi.models.person import Person from decora_wifi.models.residential_account import ResidentialAccount @@ -93,8 +93,7 @@ def supported_features(self): """Return supported features.""" if self._switch.canSetLevel: return SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION - else: - return 0 + return 0 @property def name(self): diff --git a/homeassistant/components/light/demo.py b/homeassistant/components/light/demo.py index 22ab404a3b2dd..ba27cbd3ac536 100644 --- a/homeassistant/components/light/demo.py +++ b/homeassistant/components/light/demo.py @@ -4,18 +4,16 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ -import asyncio 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'] @@ -23,17 +21,17 @@ 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): """Set up the demo light platform.""" add_devices_callback([ - DemoLight("Bed Light", False, True, effect_list=LIGHT_EFFECT_LIST, + DemoLight(1, "Bed Light", False, True, effect_list=LIGHT_EFFECT_LIST, effect=LIGHT_EFFECT_LIST[0]), - DemoLight("Ceiling Lights", True, True, + DemoLight(2, "Ceiling Lights", True, True, LIGHT_COLORS[0], LIGHT_TEMPS[1]), - DemoLight("Kitchen Lights", True, True, + DemoLight(3, "Kitchen Lights", True, True, LIGHT_COLORS[1], LIGHT_TEMPS[0]) ]) @@ -41,19 +39,20 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class DemoLight(Light): """Representation of a demo light.""" - def __init__(self, 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: @@ -65,12 +64,17 @@ def name(self) -> str: """Return the name of the light if any.""" return self._name + @property + def unique_id(self): + """Return unique ID for light.""" + return self._unique_id + @property def available(self) -> bool: """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: @@ -78,14 +82,9 @@ def brightness(self) -> int: 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: @@ -121,8 +120,8 @@ def turn_on(self, **kwargs) -> None: """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] @@ -130,9 +129,6 @@ def turn_on(self, **kwargs) -> None: 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] @@ -150,26 +146,3 @@ def turn_off(self, **kwargs) -> None: # As we have disabled polling, we need to inform # Home Assistant about updates in our state ourselves. self.schedule_update_ha_state() - - @asyncio.coroutine - def async_restore_state(self, is_on, **kwargs): - """Restore the demo state.""" - self._state = is_on - - if 'brightness' in kwargs: - self._brightness = kwargs['brightness'] - - if 'color_temp' in kwargs: - self._ct = kwargs['color_temp'] - - if 'rgb_color' in kwargs: - self._rgb = kwargs['rgb_color'] - - if 'xy_color' in kwargs: - self._xy_color = kwargs['xy_color'] - - if 'white_value' in kwargs: - self._white = kwargs['white_value'] - - if 'effect' in kwargs: - self._effect = kwargs['effect'] diff --git a/homeassistant/components/light/eufy.py b/homeassistant/components/light/eufy.py new file mode 100644 index 0000000000000..6f0a8816eea17 --- /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 209c3ab772411..6c7f2e98e37dc 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -12,12 +12,13 @@ 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.19'] +REQUIREMENTS = ['flux_led==0.21'] _LOGGER = logging.getLogger(__name__) @@ -27,7 +28,7 @@ DOMAIN = 'flux_led' SUPPORT_FLUX_LED = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | - SUPPORT_RGB_COLOR) + SUPPORT_COLOR) MODE_RGB = 'rgb' MODE_RGBW = 'rgbw' @@ -46,7 +47,7 @@ EFFECT_COLORSTROBE = 'colorstrobe' EFFECT_RED_STROBE = 'red_strobe' EFFECT_GREEN_STROBE = 'green_strobe' -EFFECT_BLUE_STOBE = 'blue_strobe' +EFFECT_BLUE_STROBE = 'blue_strobe' EFFECT_YELLOW_STROBE = 'yellow_strobe' EFFECT_CYAN_STROBE = 'cyan_strobe' EFFECT_PURPLE_STROBE = 'purple_strobe' @@ -68,7 +69,7 @@ EFFECT_COLORSTROBE: 0x30, EFFECT_RED_STROBE: 0x31, EFFECT_GREEN_STROBE: 0x32, - EFFECT_BLUE_STOBE: 0x33, + EFFECT_BLUE_STROBE: 0x33, EFFECT_YELLOW_STROBE: 0x34, EFFECT_CYAN_STROBE: 0x35, EFFECT_PURPLE_STROBE: 0x36, @@ -78,13 +79,13 @@ FLUX_EFFECT_LIST = [ EFFECT_RANDOM, - ].extend(EFFECT_MAP.keys()) + ] + list(EFFECT_MAP) DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(ATTR_MODE, default=MODE_RGBW): vol.All(cv.string, vol.In([MODE_RGBW, MODE_RGB])), - vol.Optional(CONF_PROTOCOL, default=None): + vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(['ledenet'])), }) @@ -104,7 +105,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): device = {} device['name'] = device_config[CONF_NAME] device['ipaddr'] = ipaddr - device[CONF_PROTOCOL] = device_config[CONF_PROTOCOL] + device[CONF_PROTOCOL] = device_config.get(CONF_PROTOCOL) device[ATTR_MODE] = device_config[ATTR_MODE] light = FluxLight(device) lights.append(light) @@ -167,11 +168,6 @@ def available(self) -> bool: """Return True if entity is available.""" return self._bulb is not None - @property - def unique_id(self): - """Return the ID of this light.""" - return '{}.{}'.format(self.__class__, self._ipaddr) - @property def name(self): """Return the name of the device if any.""" @@ -188,15 +184,23 @@ def brightness(self): 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.""" @@ -207,27 +211,40 @@ def turn_on(self, **kwargs): 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) + + # color change only + if rgb is not None: + self._bulb.setRgb(*tuple(rgb), brightness=self.brightness) - 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)) + # brightness change only 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) + (red, green, blue) = self._bulb.getRgb() + self._bulb.setRgb(red, green, blue, brightness=brightness) + + # random color effect elif effect == EFFECT_RANDOM: self._bulb.setRgb(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) + + # effect selection elif effect in EFFECT_MAP: self._bulb.setPresetPattern(EFFECT_MAP[effect], 50) + # white change only + elif white is not None: + self._bulb.setWarmWhite255(white) + def turn_off(self, **kwargs): """Turn the specified or all lights off.""" self._bulb.turnOff() diff --git a/homeassistant/components/light/greenwave.py b/homeassistant/components/light/greenwave.py new file mode 100644 index 0000000000000..8e9d93657cef2 --- /dev/null +++ b/homeassistant/components/light/greenwave.py @@ -0,0 +1,144 @@ +""" +Support for Greenwave Reality (TCP Connected) lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.greenwave/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light) +from homeassistant.const import CONF_HOST +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +REQUIREMENTS = ['greenwavereality==0.5.1'] +_LOGGER = logging.getLogger(__name__) + +CONF_VERSION = 'version' + +SUPPORTED_FEATURES = SUPPORT_BRIGHTNESS + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_VERSION): cv.positive_int, +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Greenwave Reality Platform.""" + import greenwavereality as greenwave + import os + host = config.get(CONF_HOST) + tokenfile = hass.config.path('.greenwave') + if config.get(CONF_VERSION) == 3: + if os.path.exists(tokenfile): + with open(tokenfile) as tokenfile: + token = tokenfile.read() + else: + try: + token = greenwave.grab_token(host, 'hass', 'homeassistant') + except PermissionError: + _LOGGER.error('The Gateway Is Not In Sync Mode') + raise + with open(tokenfile, "w+") as tokenfile: + tokenfile.write(token) + else: + token = None + bulbs = greenwave.grab_bulbs(host, token) + add_devices(GreenwaveLight(device, host, token, GatewayData(host, token)) + for device in bulbs.values()) + + +class GreenwaveLight(Light): + """Representation of an Greenwave Reality Light.""" + + def __init__(self, light, host, token, gatewaydata): + """Initialize a Greenwave Reality Light.""" + import greenwavereality as greenwave + self._did = int(light['did']) + self._name = light['name'] + self._state = int(light['state']) + self._brightness = greenwave.hass_brightness(light) + self._host = host + self._online = greenwave.check_online(light) + self._token = token + self._gatewaydata = gatewaydata + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORTED_FEATURES + + @property + def available(self): + """Return True if entity is available.""" + return self._online + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + @property + def brightness(self): + """Return the brightness of the light.""" + return self._brightness + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + import greenwavereality as greenwave + temp_brightness = int((kwargs.get(ATTR_BRIGHTNESS, 255) + / 255) * 100) + greenwave.set_brightness(self._host, self._did, + temp_brightness, self._token) + greenwave.turn_on(self._host, self._did, self._token) + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + import greenwavereality as greenwave + greenwave.turn_off(self._host, self._did, self._token) + + def update(self): + """Fetch new state data for this light.""" + import greenwavereality as greenwave + self._gatewaydata.update() + bulbs = self._gatewaydata.greenwave + + self._state = int(bulbs[self._did]['state']) + self._brightness = greenwave.hass_brightness(bulbs[self._did]) + self._online = greenwave.check_online(bulbs[self._did]) + self._name = bulbs[self._did]['name'] + + +class GatewayData(object): + """Handle Gateway data and limit updates.""" + + def __init__(self, host, token): + """Initialize the data object.""" + import greenwavereality as greenwave + self._host = host + self._token = token + self._greenwave = greenwave.grab_bulbs(host, token) + + @property + def greenwave(self): + """Return Gateway API object.""" + return self._greenwave + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the gateway.""" + import greenwavereality as greenwave + self._greenwave = greenwave.grab_bulbs(self._host, self._token) + return self._greenwave diff --git a/homeassistant/components/light/group.py b/homeassistant/components/light/group.py new file mode 100644 index 0000000000000..f9ffbb4e0bf72 --- /dev/null +++ b/homeassistant/components/light/group.py @@ -0,0 +1,275 @@ +""" +This platform allows several lights to be grouped into one light. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.group/ +""" +import logging +import itertools +from typing import List, Tuple, Optional, Iterator, Any, Callable +from collections import Counter + +import voluptuous as vol + +from homeassistant.core import State, callback +from homeassistant.components import light +from homeassistant.const import (STATE_ON, ATTR_ENTITY_ID, CONF_NAME, + CONF_ENTITIES, STATE_UNAVAILABLE, + ATTR_SUPPORTED_FEATURES) +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.components.light import ( + 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__) + +DEFAULT_NAME = 'Light Group' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ENTITIES): cv.entities_domain(light.DOMAIN) +}) + +SUPPORT_GROUP_LIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT + | SUPPORT_FLASH | SUPPORT_COLOR | SUPPORT_TRANSITION + | SUPPORT_WHITE_VALUE) + + +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None) -> None: + """Initialize light.group platform.""" + async_add_devices([LightGroup(config.get(CONF_NAME), + config[CONF_ENTITIES])]) + + +class LightGroup(light.Light): + """Representation of a light group.""" + + def __init__(self, name: str, entity_ids: List[str]) -> None: + """Initialize a light group.""" + self._name = name # type: str + self._entity_ids = entity_ids # type: List[str] + self._is_on = False # type: bool + self._available = False # type: bool + self._brightness = None # type: Optional[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] + self._white_value = None # type: Optional[int] + self._effect_list = None # type: Optional[List[str]] + self._effect = None # type: Optional[str] + self._supported_features = 0 # type: int + self._async_unsub_state_changed = None + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + @callback + def async_state_changed_listener(entity_id: str, old_state: State, + new_state: State): + """Handle child updates.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_state_changed = async_track_state_change( + self.hass, self._entity_ids, async_state_changed_listener) + await self.async_update() + + async def async_will_remove_from_hass(self): + """Callback when removed from HASS.""" + if self._async_unsub_state_changed is not None: + self._async_unsub_state_changed() + self._async_unsub_state_changed = None + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def is_on(self) -> bool: + """Return the on/off state of the light group.""" + return self._is_on + + @property + def available(self) -> bool: + """Return whether the light group is available.""" + return self._available + + @property + def brightness(self) -> Optional[int]: + """Return the brightness of this light group between 0..255.""" + return self._brightness + + @property + 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]: + """Return the CT color value in mireds.""" + return self._color_temp + + @property + def min_mireds(self) -> Optional[int]: + """Return the coldest color_temp that this light group supports.""" + return self._min_mireds + + @property + def max_mireds(self) -> Optional[int]: + """Return the warmest color_temp that this light group supports.""" + return self._max_mireds + + @property + def white_value(self) -> Optional[int]: + """Return the white value of this light group between 0..255.""" + return self._white_value + + @property + def effect_list(self) -> Optional[List[str]]: + """Return the list of supported effects.""" + return self._effect_list + + @property + def effect(self) -> Optional[str]: + """Return the current effect.""" + return self._effect + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + @property + def should_poll(self) -> bool: + """No polling needed for a light group.""" + return False + + async def async_turn_on(self, **kwargs): + """Forward the turn_on command to all lights in the light group.""" + data = {ATTR_ENTITY_ID: self._entity_ids} + + if ATTR_BRIGHTNESS in kwargs: + data[ATTR_BRIGHTNESS] = kwargs[ATTR_BRIGHTNESS] + + 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] + + if ATTR_WHITE_VALUE in kwargs: + data[ATTR_WHITE_VALUE] = kwargs[ATTR_WHITE_VALUE] + + if ATTR_EFFECT in kwargs: + data[ATTR_EFFECT] = kwargs[ATTR_EFFECT] + + if ATTR_TRANSITION in kwargs: + data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION] + + if ATTR_FLASH in kwargs: + data[ATTR_FLASH] = kwargs[ATTR_FLASH] + + await self.hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True) + + async def async_turn_off(self, **kwargs): + """Forward the turn_off command to all lights in the light group.""" + data = {ATTR_ENTITY_ID: self._entity_ids} + + if ATTR_TRANSITION in kwargs: + data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION] + + await self.hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_OFF, data, blocking=True) + + async def async_update(self): + """Query all members and determine the light group state.""" + all_states = [self.hass.states.get(x) for x in self._entity_ids] + states = list(filter(None, all_states)) + on_states = [state for state in states if state.state == STATE_ON] + + self._is_on = len(on_states) > 0 + self._available = any(state.state != STATE_UNAVAILABLE + for state in states) + + self._brightness = _reduce_attribute(on_states, ATTR_BRIGHTNESS) + + self._hs_color = _reduce_attribute( + on_states, ATTR_HS_COLOR, reduce=_mean_tuple) + + self._white_value = _reduce_attribute(on_states, ATTR_WHITE_VALUE) + + self._color_temp = _reduce_attribute(on_states, ATTR_COLOR_TEMP) + self._min_mireds = _reduce_attribute( + states, ATTR_MIN_MIREDS, default=154, reduce=min) + self._max_mireds = _reduce_attribute( + states, ATTR_MAX_MIREDS, default=500, reduce=max) + + self._effect_list = None + all_effect_lists = list( + _find_state_attributes(states, ATTR_EFFECT_LIST)) + if all_effect_lists: + # Merge all effects from all effect_lists with a union merge. + self._effect_list = list(set().union(*all_effect_lists)) + + self._effect = None + all_effects = list(_find_state_attributes(on_states, ATTR_EFFECT)) + if all_effects: + # Report the most common effect. + effects_count = Counter(itertools.chain(all_effects)) + self._effect = effects_count.most_common(1)[0][0] + + self._supported_features = 0 + for support in _find_state_attributes(states, ATTR_SUPPORTED_FEATURES): + # Merge supported features by emulating support for every feature + # we find. + self._supported_features |= support + # Bitwise-and the supported features with the GroupedLight's features + # so that we don't break in the future when a new feature is added. + self._supported_features &= SUPPORT_GROUP_LIGHT + + +def _find_state_attributes(states: List[State], + key: str) -> Iterator[Any]: + """Find attributes with matching key from states.""" + for state in states: + value = state.attributes.get(key) + if value is not None: + yield value + + +def _mean_int(*args): + """Return the mean of the supplied values.""" + return int(sum(args) / len(args)) + + +def _mean_tuple(*args): + """Return the mean values along the columns of the supplied values.""" + return tuple(sum(l) / len(l) for l in zip(*args)) + + +# https://github.com/PyCQA/pylint/issues/1831 +# pylint: disable=bad-whitespace +def _reduce_attribute(states: List[State], + key: str, + default: Optional[Any] = None, + reduce: Callable[..., Any] = _mean_int) -> Any: + """Find the first attribute matching key from states. + + If none are found, return default. + """ + attrs = list(_find_state_attributes(states, key)) + + if not attrs: + return default + + if len(attrs) == 1: + return attrs[0] + + return reduce(*attrs) diff --git a/homeassistant/components/light/hive.py b/homeassistant/components/light/hive.py new file mode 100644 index 0000000000000..1fd9e8aaacae7 --- /dev/null +++ b/homeassistant/components/light/hive.py @@ -0,0 +1,146 @@ +""" +Support for the Hive devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.hive/ +""" +from homeassistant.components.hive import DATA_HIVE +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 + +DEPENDENCIES = ['hive'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Hive light devices.""" + if discovery_info is None: + return + session = hass.data.get(DATA_HIVE) + + add_devices([HiveDeviceLight(session, discovery_info)]) + + +class HiveDeviceLight(Light): + """Hive Active Light Device.""" + + def __init__(self, hivesession, hivedevice): + """Initialize the Light device.""" + self.node_id = hivedevice["Hive_NodeID"] + self.node_name = hivedevice["Hive_NodeName"] + 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) + + def handle_update(self, updatesource): + """Handle the new update request.""" + if '{}.{}'.format(self.device_type, self.node_id) not in updatesource: + self.schedule_update_ha_state() + + @property + def name(self): + """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).""" + return self.session.light.get_brightness(self.node_id) + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + if self.light_device_type == "tuneablelight" \ + or self.light_device_type == "colourtuneablelight": + return self.session.light.get_min_color_temp(self.node_id) + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + if self.light_device_type == "tuneablelight" \ + or self.light_device_type == "colourtuneablelight": + return self.session.light.get_max_color_temp(self.node_id) + + @property + def color_temp(self): + """Return the CT color value in mireds.""" + if self.light_device_type == "tuneablelight" \ + or self.light_device_type == "colourtuneablelight": + return self.session.light.get_color_temp(self.node_id) + + @property + def hs_color(self) -> tuple: + """Return the hs color value.""" + if self.light_device_type == "colourtuneablelight": + rgb = self.session.light.get_color(self.node_id) + return color_util.color_RGB_to_hs(*rgb) + + @property + def is_on(self): + """Return true if light is on.""" + return self.session.light.get_state(self.node_id) + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + new_brightness = None + new_color_temp = None + new_color = None + if ATTR_BRIGHTNESS in kwargs: + tmp_new_brightness = kwargs.get(ATTR_BRIGHTNESS) + percentage_brightness = ((tmp_new_brightness / 255) * 100) + new_brightness = int(round(percentage_brightness / 5.0) * 5.0) + if new_brightness == 0: + new_brightness = 5 + 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_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, + new_color) + + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self.session.light.turn_off(self.node_id) + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = None + if self.light_device_type == "warmwhitelight": + supported_features = SUPPORT_BRIGHTNESS + elif self.light_device_type == "tuneablelight": + supported_features = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP) + elif self.light_device_type == "colourtuneablelight": + supported_features = ( + 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 0000000000000..e6dc09e455cb2 --- /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/homematic.py b/homeassistant/components/light/homematic.py index 807c19fffdb32..a3db1ff30ff5c 100644 --- a/homeassistant/components/light/homematic.py +++ b/homeassistant/components/light/homematic.py @@ -1,5 +1,5 @@ """ -Support for Homematic lighs. +Support for Homematic lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.homematic/ diff --git a/homeassistant/components/light/homematicip_cloud.py b/homeassistant/components/light/homematicip_cloud.py new file mode 100644 index 0000000000000..e433da44ae768 --- /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 79d80d2b8a039..837a6f82510e5 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -1,55 +1,34 @@ """ -Support for Hue lights. +This component provides light support for the Philips Hue system. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.hue/ """ -import json +import asyncio +from datetime import timedelta import logging -import os import random -import socket -from datetime import timedelta -import voluptuous as vol +import async_timeout -import homeassistant.util as util -import homeassistant.util.color as color_util +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, + 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_RGB_COLOR, SUPPORT_TRANSITION, - SUPPORT_XY_COLOR, Light, PLATFORM_SCHEMA) -from homeassistant.config import load_yaml_config_file -from homeassistant.const import (CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME) -from homeassistant.components.emulated_hue import ATTR_EMULATED_HUE -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['phue==1.0'] - -# Track previously setup bridges -_CONFIGURED_BRIDGES = {} -# Map ip to request id for configuring -_CONFIGURING = {} -_LOGGER = logging.getLogger(__name__) - -CONF_ALLOW_UNREACHABLE = 'allow_unreachable' - -DEFAULT_ALLOW_UNREACHABLE = False -DOMAIN = "light" -SERVICE_HUE_SCENE = "hue_activate_scene" + SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_COLOR, SUPPORT_TRANSITION, + Light) +from homeassistant.util import color -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) +DEPENDENCIES = ['hue'] +SCAN_INTERVAL = timedelta(seconds=5) -PHUE_CONFIG_FILE = 'phue.conf' +_LOGGER = logging.getLogger(__name__) 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 = { @@ -60,357 +39,268 @@ 'Color temperature light': SUPPORT_HUE_COLOR_TEMP } -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, -}) - -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, -}) - -ATTR_IS_HUE_GROUP = "is_hue_group" -GROUP_NAME_ALL_HUE_LIGHTS = "All Hue Lights" - - -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(json.loads(''.join(inp)).keys().__iter__()) - 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 - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Hue lights.""" - # Default needed in case of discovery - filename = config.get(CONF_FILENAME, PHUE_CONFIG_FILE) - allow_unreachable = config.get(CONF_ALLOW_UNREACHABLE, - DEFAULT_ALLOW_UNREACHABLE) - allow_in_emulated_hue = config.get(CONF_ALLOW_IN_EMULATED_HUE, - DEFAULT_ALLOW_IN_EMULATED_HUE) - allow_hue_groups = config.get(CONF_ALLOW_HUE_GROUPS) - - if discovery_info is not None: - if "HASS Bridge" in discovery_info.get('name', ''): - _LOGGER.info("Emulated hue found, will not add") - return False - - host = discovery_info.get('host') +ATTR_IS_HUE_GROUP = 'is_hue_group' +# 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) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Old way of setting up Hue lights. + + 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 + + +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() + + +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 is_group: + api = bridge.api.groups else: - host = config.get(CONF_HOST, None) - - if host is None: - host = _find_host_from_config(hass, filename) - - if host is None: - _LOGGER.error("No host found in configuration") - return False - - # Only act if we are not already configuring this host - if host in _CONFIGURING or \ - socket.gethostbyname(host) in _CONFIGURED_BRIDGES: - return - - setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) - - -def setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups): - """Set up a phue bridge based on host parameter.""" - import phue + api = bridge.api.lights try: - bridge = phue.Bridge( - host, - config_file_path=hass.config.path(filename)) - except ConnectionRefusedError: # Wrong host was given - _LOGGER.error("Error connecting to the Hue bridge at %s", host) - - return - - except phue.PhueRegistrationException: - _LOGGER.warning("Connected to Hue at %s but not registered.", host) - - request_configuration(host, hass, add_devices, filename, - allow_unreachable, allow_in_emulated_hue, - allow_hue_groups) - - return - - # If we came here and configuring this host, mark as done - if host in _CONFIGURING: - request_id = _CONFIGURING.pop(host) - configurator = hass.components.configurator - configurator.request_done(request_id) - - lights = {} - lightgroups = {} - skip_groups = not allow_hue_groups - - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update_lights(): - """Update the Hue light objects with latest info from the bridge.""" - nonlocal skip_groups - - try: - api = bridge.get_api() - except phue.PhueRequestTimeout: - _LOGGER.warning("Timeout trying to reach the bridge") - return - except ConnectionRefusedError: - _LOGGER.error("The bridge refused the connection") - return - except socket.error: - # socket.error when we cannot reach Hue - _LOGGER.exception("Cannot reach the bridge") - return - - api_lights = api.get('lights') - - if not isinstance(api_lights, dict): - _LOGGER.error("Got unexpected result from Hue API") - return - - if skip_groups: - api_groups = {} - else: - api_groups = api.get('groups') - - if not isinstance(api_groups, dict): - _LOGGER.error("Got unexpected result from Hue API") + with async_timeout.timeout(4): + await api.update() + except (asyncio.TimeoutError, aiohue.AiohueException): + if not bridge.available: return - if not skip_groups: - # Group ID 0 is a special group in the hub for all lights, but it - # is not returned by get_api() so explicity get it and include it. - # See https://developers.meethue.com/documentation/ - # groups-api#21_get_all_groups - _LOGGER.debug("Getting group 0 from bridge") - all_lights = bridge.get_group(0) - if not isinstance(all_lights, dict): - _LOGGER.error("Got unexpected result from Hue API for group 0") - return - # Hue hub returns name of group 0 as "Group 0", so rename - # for ease of use in HA. - all_lights['name'] = GROUP_NAME_ALL_HUE_LIGHTS - api_groups["0"] = all_lights - - new_lights = [] - - api_name = api.get('config').get('name') - if api_name in ('RaspBee-GW', 'deCONZ-GW'): - bridge_type = 'deconz' - else: - bridge_type = 'hue' - - for light_id, info in api_lights.items(): - if light_id not in lights: - lights[light_id] = HueLight(int(light_id), info, - bridge, update_lights, - bridge_type, allow_unreachable, - allow_in_emulated_hue) - new_lights.append(lights[light_id]) - else: - lights[light_id].info = info - lights[light_id].schedule_update_ha_state() - - 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.") - skip_groups = True - break - - if lightgroup_id not in lightgroups: - lightgroups[lightgroup_id] = HueLight( - int(lightgroup_id), info, bridge, update_lights, - bridge_type, allow_unreachable, allow_in_emulated_hue, - True) - new_lights.append(lightgroups[lightgroup_id]) - else: - lightgroups[lightgroup_id].info = info - lightgroups[lightgroup_id].schedule_update_ha_state() - - if new_lights: - add_devices(new_lights) - - _CONFIGURED_BRIDGES[socket.gethostbyname(host)] = True + _LOGGER.error('Unable to reach bridge %s', bridge.host) + bridge.available = False - # 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 directly into bridge to set scenes.""" - group_name = call.data[ATTR_GROUP_NAME] - scene_name = call.data[ATTR_SCENE_NAME] - bridge.run_scene(group_name, scene_name) - - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - hass.services.register(DOMAIN, SERVICE_HUE_SCENE, hue_activate_scene, - descriptions.get(SERVICE_HUE_SCENE), - schema=SCENE_SCHEMA) - - update_lights() + for light_id, light in current.items(): + if light_id not in progress_waiting: + light.async_schedule_update_ha_state() + return -def request_configuration(host, hass, add_devices, filename, - allow_unreachable, allow_in_emulated_hue, - allow_hue_groups): - """Request configuration steps from the user.""" - configurator = hass.components.configurator + if not bridge.available: + _LOGGER.info('Reconnected to bridge %s', bridge.host) + bridge.available = True - # 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.") + new_lights = [] - return + for item_id in api: + if item_id not in current: + current[item_id] = HueLight( + api[item_id], request_bridge_update, bridge, is_group) - # pylint: disable=unused-argument - def hue_configuration_callback(data): - """Set up actions to do when our configuration callback is called.""" - setup_bridge(host, hass, add_devices, filename, allow_unreachable, - allow_in_emulated_hue, allow_hue_groups) + new_lights.append(current[item_id]) + elif item_id not in progress_waiting: + current[item_id].async_schedule_update_ha_state() - _CONFIGURING[host] = configurator.request_config( - "Philips Hue", hue_configuration_callback, - description=("Press the button on the bridge to register Philips Hue " - "with Home Assistant."), - entity_picture="/static/images/logo_philips_hue.png", - description_image="/static/images/config_philips_hue.jpg", - submit_caption="I have pressed the button" - ) + if new_lights: + async_add_devices(new_lights) class HueLight(Light): """Representation of a Hue light.""" - def __init__(self, light_id, info, bridge, update_lights, - bridge_type, 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 - self.bridge_type = bridge_type - 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.""" - lid = self.info.get('uniqueid') - - if lid is None: - default_type = 'Group' if self.is_group else 'Light' - ltype = self.info.get('type', default_type) - lid = '{}.{}.{}'.format(self.name, ltype, self.light_id) - - return '{}.{}'.format(self.__class__, lid) + 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'] - elif self.allow_unreachable: - return self.info['state']['on'] - return self.info['state']['reachable'] and \ - 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.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": - hue, sat = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) - command['hue'] = hue - command['sat'] = sat - 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] - command['sat'] = hsv[1] - command['bri'] = hsv[2] + 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: - xyb = color_util.color_RGB_to_xy( - *(int(val) for val in kwargs[ATTR_RGB_COLOR])) - command['xy'] = xyb[0], xyb[1] - command['bri'] = xyb[2] + # 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)) @@ -426,7 +316,7 @@ def turn_on(self, **kwargs): elif flash == FLASH_SHORT: command['alert'] = 'select' del command['on'] - elif self.bridge_type == 'hue': + else: command['alert'] = 'none' effect = kwargs.get(ATTR_EFFECT) @@ -436,13 +326,15 @@ def turn_on(self, **kwargs): elif effect == EFFECT_RANDOM: command['hue'] = random.randrange(0, 65535) command['sat'] = random.randrange(150, 254) - elif (self.bridge_type == 'hue' and - 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} @@ -457,21 +349,22 @@ def turn_off(self, **kwargs): elif flash == FLASH_SHORT: command['alert'] = 'select' del command['on'] - elif self.bridge_type == 'hue': + 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] = self.allow_in_emulated_hue if self.is_group: attributes[ATTR_IS_HUE_GROUP] = self.is_group return attributes diff --git a/homeassistant/components/light/hyperion.py b/homeassistant/components/light/hyperion.py index ec91ba582fb54..8ba2329af7e0b 100644 --- a/homeassistant/components/light/hyperion.py +++ b/homeassistant/components/light/hyperion.py @@ -11,19 +11,37 @@ import voluptuous as vol from homeassistant.components.light import ( - ATTR_RGB_COLOR, SUPPORT_RGB_COLOR, 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__) CONF_DEFAULT_COLOR = 'default_color' +CONF_PRIORITY = 'priority' +CONF_HDMI_PRIORITY = 'hdmi_priority' +CONF_EFFECT_LIST = 'effect_list' DEFAULT_COLOR = [255, 255, 255] DEFAULT_NAME = 'Hyperion' DEFAULT_PORT = 19444 +DEFAULT_PRIORITY = 128 +DEFAULT_HDMI_PRIORITY = 880 +DEFAULT_EFFECT_LIST = ['HDMI', 'Cinema brighten lights', 'Cinema dim lights', + 'Knight rider', 'Blue mood blobs', 'Cold mood blobs', + 'Full color mood blobs', 'Green mood blobs', + 'Red mood blobs', 'Warm mood blobs', + 'Police Lights Single', 'Police Lights Solid', + 'Rainbow mood', 'Rainbow swirl fast', + 'Rainbow swirl', 'Random', 'Running dots', + 'System Shutdown', 'Snake', 'Sparks Color', 'Sparks', + 'Strobe blue', 'Strobe Raspbmc', 'Strobe white', + 'Color traces', 'UDP multicast listener', + 'UDP listener', 'X-Mas'] -SUPPORT_HYPERION = SUPPORT_RGB_COLOR +SUPPORT_HYPERION = (SUPPORT_COLOR | SUPPORT_BRIGHTNESS | SUPPORT_EFFECT) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -32,6 +50,12 @@ vol.All(list, vol.Length(min=3, max=3), [vol.All(vol.Coerce(int), vol.Range(min=0, max=255))]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PRIORITY, default=DEFAULT_PRIORITY): cv.positive_int, + vol.Optional(CONF_HDMI_PRIORITY, + default=DEFAULT_HDMI_PRIORITY): cv.positive_int, + vol.Optional(CONF_EFFECT_LIST, + default=DEFAULT_EFFECT_LIST): vol.All(cv.ensure_list, + [cv.string]), }) @@ -39,9 +63,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a Hyperion server remote.""" host = config.get(CONF_HOST) port = config.get(CONF_PORT) + priority = config.get(CONF_PRIORITY) + hdmi_priority = config.get(CONF_HDMI_PRIORITY) default_color = config.get(CONF_DEFAULT_COLOR) + effect_list = config.get(CONF_EFFECT_LIST) - device = Hyperion(config.get(CONF_NAME), host, port, default_color) + device = Hyperion(config.get(CONF_NAME), host, port, priority, + default_color, hdmi_priority, effect_list) if device.setup(): add_devices([device]) @@ -52,13 +80,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class Hyperion(Light): """Representation of a Hyperion remote.""" - def __init__(self, name, host, port, default_color): + def __init__(self, name, host, port, priority, default_color, + hdmi_priority, effect_list): """Initialize the light.""" self._host = host self._port = port self._name = name + self._priority = priority + self._hdmi_priority = hdmi_priority self._default_color = default_color self._rgb_color = [0, 0, 0] + self._rgb_mem = [0, 0, 0] + self._brightness = 255 + self._icon = 'mdi:lightbulb' + self._effect_list = effect_list + self._effect = None + self._skip_update = False @property def name(self): @@ -66,15 +103,35 @@ def name(self): return self._name @property - def rgb_color(self): - """Return last RGB color value set.""" - return self._rgb_color + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def hs_color(self): + """Return last color value set.""" + return color_util.color_RGB_to_hs(*self._rgb_color) @property def is_on(self): """Return true if not black.""" return self._rgb_color != [0, 0, 0] + @property + def icon(self): + """Return state specific icon.""" + return self._icon + + @property + def effect(self): + """Return the current effect.""" + return self._effect + + @property + def effect_list(self): + """Return the list of supported effects.""" + return self._effect_list + @property def supported_features(self): """Flag supported features.""" @@ -82,33 +139,107 @@ def supported_features(self): def turn_on(self, **kwargs): """Turn the lights on.""" - if ATTR_RGB_COLOR in kwargs: - self._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: + rgb_color = self._rgb_mem + + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] else: - self._rgb_color = self._default_color + brightness = self._brightness - self.json_request( - {'command': 'color', 'priority': 128, 'color': self._rgb_color}) + if ATTR_EFFECT in kwargs: + self._skip_update = True + self._effect = kwargs[ATTR_EFFECT] + if self._effect == 'HDMI': + self.json_request({'command': 'clearall'}) + self._icon = 'mdi:video-input-hdmi' + self._brightness = 255 + self._rgb_color = [125, 125, 125] + else: + self.json_request({ + 'command': 'effect', + 'priority': self._priority, + 'effect': {'name': self._effect} + }) + self._icon = 'mdi:lava-lamp' + self._rgb_color = [175, 0, 255] + return + + cal_color = [int(round(x*float(brightness)/255)) + for x in rgb_color] + self.json_request({ + 'command': 'color', + 'priority': self._priority, + 'color': cal_color + }) def turn_off(self, **kwargs): """Disconnect all remotes.""" self.json_request({'command': 'clearall'}) - self._rgb_color = [0, 0, 0] + self.json_request({ + 'command': 'color', + 'priority': self._priority, + 'color': [0, 0, 0] + }) def update(self): - """Get the remote's active color.""" + """Get the lights status.""" + # postpone the immediate state check for changes that take time + if self._skip_update: + self._skip_update = False + return response = self.json_request({'command': 'serverinfo'}) if response: # workaround for outdated Hyperion if 'activeLedColor' not in response['info']: self._rgb_color = self._default_color + self._rgb_mem = self._default_color + self._brightness = 255 + self._icon = 'mdi:lightbulb' + self._effect = None return + # Check if Hyperion is in ambilight mode trough an HDMI grabber + try: + active_priority = response['info']['priorities'][0]['priority'] + if active_priority == self._hdmi_priority: + self._brightness = 255 + self._rgb_color = [125, 125, 125] + self._icon = 'mdi:video-input-hdmi' + self._effect = 'HDMI' + return + except (KeyError, IndexError): + pass - if response['info']['activeLedColor'] == []: - self._rgb_color = [0, 0, 0] + led_color = response['info']['activeLedColor'] + 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] + self._icon = 'mdi:lava-lamp' + try: + s_name = response['info']['activeEffects'][0]["script"] + s_name = s_name.split('/')[-1][:-3].split("-")[0] + self._effect = [x for x in self._effect_list + if s_name.lower() in x.lower()][0] + except (KeyError, IndexError): + self._effect = None + # Bulb off state + else: + self._rgb_color = [0, 0, 0] + self._icon = 'mdi:lightbulb' + self._effect = None else: - self._rgb_color =\ - response['info']['activeLedColor'][0]['RGB Value'] + # Get the RGB color + 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] + self._icon = 'mdi:lightbulb' + self._effect = None def setup(self): """Get the hostname of the remote.""" diff --git a/homeassistant/components/light/iglo.py b/homeassistant/components/light/iglo.py new file mode 100644 index 0000000000000..f40dc2ce84eea --- /dev/null +++ b/homeassistant/components/light/iglo.py @@ -0,0 +1,132 @@ +""" +Support for lights under the iGlo brand. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.iglo/ +""" +import logging +import math + +import voluptuous as vol + +from homeassistant.components.light import ( + 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 +import homeassistant.util.color as color_util + +REQUIREMENTS = ['iglo==1.2.7'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'iGlo Light' +DEFAULT_PORT = 8080 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the iGlo lights.""" + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + port = config.get(CONF_PORT) + add_devices([IGloLamp(name, host, port)], True) + + +class IGloLamp(Light): + """Representation of an iGlo light.""" + + def __init__(self, name, host, port): + """Initialize the light.""" + from iglo import Lamp + self._name = name + self._lamp = Lamp(0, host, port) + + @property + def name(self): + """Return the name of the light.""" + return self._name + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return int((self._lamp.state()['brightness'] / 200.0) * 255) + + @property + def color_temp(self): + """Return the color temperature.""" + return color_util.color_temperature_kelvin_to_mired( + self._lamp.state()['white']) + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return math.ceil(color_util.color_temperature_kelvin_to_mired( + self._lamp.max_kelvin)) + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return math.ceil(color_util.color_temperature_kelvin_to_mired( + self._lamp.min_kelvin)) + + @property + def hs_color(self): + """Return the hs value.""" + return color_util.color_RGB_to_hs(*self._lamp.state()['rgb']) + + @property + def effect(self): + """Return the current effect.""" + return self._lamp.state()['effect'] + + @property + def effect_list(self): + """Return the list of supported effects.""" + return self._lamp.effect_list() + + @property + def supported_features(self): + """Flag supported features.""" + return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | + SUPPORT_COLOR | SUPPORT_EFFECT) + + @property + def is_on(self): + """Return true if light is on.""" + return self._lamp.state()['on'] + + def turn_on(self, **kwargs): + """Turn the light on.""" + if not self.is_on: + self._lamp.switch(True) + if ATTR_BRIGHTNESS in kwargs: + brightness = int((kwargs[ATTR_BRIGHTNESS] / 255.0) * 200.0) + self._lamp.brightness(brightness) + return + + if ATTR_HS_COLOR in kwargs: + rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) + self._lamp.rgb(*rgb) + return + + if ATTR_COLOR_TEMP in kwargs: + kelvin = int(color_util.color_temperature_mired_to_kelvin( + kwargs[ATTR_COLOR_TEMP])) + self._lamp.white(kelvin) + return + + if ATTR_EFFECT in kwargs: + effect = kwargs[ATTR_EFFECT] + self._lamp.effect(effect) + return + + def turn_off(self, **kwargs): + """Turn the light off.""" + self._lamp.switch(False) diff --git a/homeassistant/components/light/ihc.py b/homeassistant/components/light/ihc.py new file mode 100644 index 0000000000000..c9ceda8651ac4 --- /dev/null +++ b/homeassistant/components/light/ihc.py @@ -0,0 +1,123 @@ +"""IHC light platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.ihc/ +""" +from xml.etree.ElementTree import Element + +import voluptuous as vol + +from homeassistant.components.ihc import ( + validate_name, IHC_DATA, IHC_CONTROLLER, IHC_INFO) +from homeassistant.components.ihc.const import CONF_DIMMABLE +from homeassistant.components.ihc.ihcdevice import IHCDevice +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, PLATFORM_SCHEMA, Light) +from homeassistant.const import CONF_ID, CONF_NAME, CONF_LIGHTS +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['ihc'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_LIGHTS, default=[]): + vol.All(cv.ensure_list, [ + vol.All({ + vol.Required(CONF_ID): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_DIMMABLE, default=False): cv.boolean, + }, validate_name) + ]) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the ihc lights platform.""" + ihc_controller = hass.data[IHC_DATA][IHC_CONTROLLER] + info = hass.data[IHC_DATA][IHC_INFO] + devices = [] + if discovery_info: + for name, device in discovery_info.items(): + ihc_id = device['ihc_id'] + product_cfg = device['product_cfg'] + product = device['product'] + light = IhcLight(ihc_controller, name, ihc_id, info, + product_cfg[CONF_DIMMABLE], product) + devices.append(light) + else: + lights = config[CONF_LIGHTS] + for light in lights: + ihc_id = light[CONF_ID] + name = light[CONF_NAME] + dimmable = light[CONF_DIMMABLE] + device = IhcLight(ihc_controller, name, ihc_id, info, dimmable) + devices.append(device) + + add_devices(devices) + + +class IhcLight(IHCDevice, Light): + """Representation of a IHC light. + + For dimmable lights, the associated IHC resource should be a light + level (integer). For non dimmable light the IHC resource should be + an on/off (boolean) resource + """ + + def __init__(self, ihc_controller, name, ihc_id: int, info: bool, + dimmable=False, product: Element = None) -> None: + """Initialize the light.""" + super().__init__(ihc_controller, name, ihc_id, info, product) + self._brightness = 0 + self._dimmable = dimmable + self._state = None + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self._state + + @property + def supported_features(self): + """Flag supported features.""" + if self._dimmable: + return SUPPORT_BRIGHTNESS + return 0 + + def turn_on(self, **kwargs) -> None: + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + else: + brightness = self._brightness + if brightness == 0: + brightness = 255 + + if self._dimmable: + self.ihc_controller.set_runtime_value_int( + self.ihc_id, int(brightness * 100 / 255)) + else: + self.ihc_controller.set_runtime_value_bool(self.ihc_id, True) + + def turn_off(self, **kwargs) -> None: + """Turn the light off.""" + if self._dimmable: + self.ihc_controller.set_runtime_value_int(self.ihc_id, 0) + else: + self.ihc_controller.set_runtime_value_bool(self.ihc_id, False) + + def on_ihc_change(self, ihc_id, value): + """Callback from Ihc notifications.""" + if isinstance(value, bool): + self._dimmable = False + self._state = value != 0 + else: + self._dimmable = True + self._state = value > 0 + if self._state: + self._brightness = int(value * 255 / 100) + self.schedule_update_ha_state() diff --git a/homeassistant/components/light/insteon_local.py b/homeassistant/components/light/insteon_local.py index ebd6ab92d0fd3..bd7814df8f3ec 100644 --- a/homeassistant/components/light/insteon_local.py +++ b/homeassistant/components/light/insteon_local.py @@ -4,9 +4,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/light.insteon_local/ """ -import json import logging -import os from datetime import timedelta from homeassistant.components.light import ( @@ -19,8 +17,6 @@ DEPENDENCIES = ['insteon_local'] DOMAIN = 'light' -INSTEON_LOCAL_LIGHTS_CONF = 'insteon_local_lights.conf' - MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) @@ -30,117 +26,38 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Insteon local light platform.""" insteonhub = hass.data['insteon_local'] - - conf_lights = config_from_file(hass.config.path(INSTEON_LOCAL_LIGHTS_CONF)) - if conf_lights: - for device_id in conf_lights: - setup_light(device_id, conf_lights[device_id], insteonhub, hass, - add_devices) - - else: - linked = insteonhub.get_linked() - - for device_id in linked: - if (linked[device_id]['cat_type'] == 'dimmer' and - device_id not in conf_lights): - request_configuration(device_id, - insteonhub, - linked[device_id]['model_name'] + ' ' + - linked[device_id]['sku'], - hass, add_devices) - - -def request_configuration(device_id, insteonhub, model, hass, - add_devices_callback): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - # We got an error if this method is called while we are configuring - if device_id in _CONFIGURING: - configurator.notify_errors( - _CONFIGURING[device_id], 'Failed to register, please try again.') - + if discovery_info is None: return - def insteon_light_config_callback(data): - """Set up actions to do when our configuration callback is called.""" - setup_light(device_id, data.get('name'), insteonhub, hass, - add_devices_callback) - - _CONFIGURING[device_id] = configurator.request_config( - 'Insteon ' + model + ' addr: ' + device_id, - insteon_light_config_callback, - description=('Enter a name for ' + model + ' addr: ' + device_id), - entity_picture='/static/images/config_insteon.png', - submit_caption='Confirm', - fields=[{'id': 'name', 'name': 'Name', 'type': ''}] - ) - - -def setup_light(device_id, name, insteonhub, hass, add_devices_callback): - """Set up the light.""" - if device_id in _CONFIGURING: - request_id = _CONFIGURING.pop(device_id) - configurator = hass.components.configurator - configurator.request_done(request_id) - _LOGGER.debug("Device configuration done") - - conf_lights = config_from_file(hass.config.path(INSTEON_LOCAL_LIGHTS_CONF)) - if device_id not in conf_lights: - conf_lights[device_id] = name - - if not config_from_file( - hass.config.path(INSTEON_LOCAL_LIGHTS_CONF), - conf_lights): - _LOGGER.error("Failed to save configuration file") - - device = insteonhub.dimmer(device_id) - add_devices_callback([InsteonLocalDimmerDevice(device, name)]) - - -def config_from_file(filename, config=None): - """Small configuration file management function.""" - if config: - # We're writing configuration - try: - with open(filename, 'w') as fdesc: - fdesc.write(json.dumps(config)) - except IOError as error: - _LOGGER.error("Saving config file failed: %s", error) - return False - return True - else: - # We're reading config - if os.path.isfile(filename): - try: - with open(filename, 'r') as fdesc: - return json.loads(fdesc.read()) - except IOError as error: - _LOGGER.error("Reading configuration file failed: %s", error) - # This won't work yet - return False - else: - return {} + linked = discovery_info['linked'] + device_list = [] + for device_id in linked: + if linked[device_id]['cat_type'] == 'dimmer': + device = insteonhub.dimmer(device_id) + device_list.append( + InsteonLocalDimmerDevice(device) + ) + + add_devices(device_list) class InsteonLocalDimmerDevice(Light): """An abstract Class for an Insteon node.""" - def __init__(self, node, name): + def __init__(self, node): """Initialize the device.""" self.node = node - self.node.deviceName = name self._value = 0 @property def name(self): - """Return the the name of the node.""" - return self.node.deviceName + """Return the name of the node.""" + return self.node.device_id @property def unique_id(self): """Return the ID of this Insteon node.""" - return 'insteon_local_{}'.format(self.node.device_id) + return self.node.device_id @property def brightness(self): diff --git a/homeassistant/components/light/insteon_plm.py b/homeassistant/components/light/insteon_plm.py index 3b3dd43f49659..8a3b463c2bd08 100644 --- a/homeassistant/components/light/insteon_plm.py +++ b/homeassistant/components/light/insteon_plm.py @@ -2,15 +2,14 @@ Support for Insteon lights via PowerLinc Modem. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/insteon_plm/ +https://home-assistant.io/components/light.insteon_plm/ """ -import logging import asyncio +import logging -from homeassistant.core import callback +from homeassistant.components.insteon_plm import InsteonPLMEntity from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) -from homeassistant.loader import get_component _LOGGER = logging.getLogger(__name__) @@ -22,98 +21,49 @@ @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') - device_list = [] - for device in discovery_info: - name = device.get('address') - address = device.get('address_hex') - dimmable = bool('dimmable' in device.get('capabilities')) + address = discovery_info['address'] + device = plm.devices[address] + state_key = discovery_info['state_key'] - _LOGGER.info("Registered %s with light platform", name) + _LOGGER.debug('Adding device %s entity %s to Light platform', + device.address.hex, device.states[state_key].name) - device_list.append( - InsteonPLMDimmerDevice(hass, plm, address, name, dimmable) - ) + new_entity = InsteonPLMDimmerDevice(device, state_key) - async_add_devices(device_list) + async_add_devices([new_entity]) -class InsteonPLMDimmerDevice(Light): +class InsteonPLMDimmerDevice(InsteonPLMEntity, Light): """A Class for an Insteon device.""" - def __init__(self, hass, plm, address, name, dimmable): - """Initialize the light.""" - self._hass = hass - self._plm = plm.protocol - self._address = address - self._name = name - self._dimmable = dimmable - - self._plm.add_update_callback( - self.async_light_update, {'address': self._address}) - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def address(self): - """Return the the address of the node.""" - return self._address - - @property - def name(self): - """Return the the name of the node.""" - return self._name - @property def brightness(self): """Return the brightness of this light between 0..255.""" - onlevel = self._plm.get_device_attr(self._address, 'onlevel') - _LOGGER.debug("on level for %s is %s", self._address, onlevel) + onlevel = self._insteon_device_state.value return int(onlevel) @property def is_on(self): """Return the boolean response if the node is on.""" - onlevel = self._plm.get_device_attr(self._address, 'onlevel') - _LOGGER.debug("on level for %s is %s", self._address, onlevel) - return bool(onlevel) + return bool(self.brightness) @property def supported_features(self): """Flag supported features.""" - if self._dimmable: - return SUPPORT_BRIGHTNESS - - @property - def device_state_attributes(self): - """Provide attributes for display on device card.""" - insteon_plm = get_component('insteon_plm') - return insteon_plm.common_attributes(self) - - def get_attr(self, key): - """Return specified attribute for this device.""" - return self._plm.get_device_attr(self.address, key) - - @callback - def async_light_update(self, message): - """Receive notification from transport that new data exists.""" - _LOGGER.info("Received update calback from PLM for %s", self._address) - self._hass.async_add_job(self.async_update_ha_state()) + return SUPPORT_BRIGHTNESS @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn device on.""" if ATTR_BRIGHTNESS in kwargs: brightness = int(kwargs[ATTR_BRIGHTNESS]) + self._insteon_device_state.set_level(brightness) else: - brightness = MAX_BRIGHTNESS - self._plm.turn_on(self._address, brightness=brightness) + self._insteon_device_state.on() @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn device off.""" - self._plm.turn_off(self._address) + self._insteon_device_state.off() diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py index 78b92fbd1453d..d2ed865892e6f 100644 --- a/homeassistant/components/light/isy994.py +++ b/homeassistant/components/light/isy994.py @@ -8,40 +8,26 @@ from typing import Callable from homeassistant.components.light import ( - Light, SUPPORT_BRIGHTNESS) -import homeassistant.components.isy994 as isy -from homeassistant.const import STATE_ON, STATE_OFF + Light, SUPPORT_BRIGHTNESS, DOMAIN) +from homeassistant.components.isy994 import ISY994_NODES, ISYDevice from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) -UOM = ['2', '51', '78'] -STATES = [STATE_OFF, STATE_ON, 'true', 'false', '%'] - # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 light platform.""" - if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error("A connection has not been made to the ISY controller") - return False - devices = [] - - for node in isy.filter_nodes(isy.NODES, units=UOM, states=STATES): - if node.dimmable or '51' in node.uom: - devices.append(ISYLightDevice(node)) + for node in hass.data[ISY994_NODES][DOMAIN]: + devices.append(ISYLightDevice(node)) add_devices(devices) -class ISYLightDevice(isy.ISYDevice, Light): - """Representation of an ISY994 light devie.""" - - def __init__(self, node: object) -> None: - """Initialize the ISY994 light device.""" - isy.ISYDevice.__init__(self, node) +class ISYLightDevice(ISYDevice, Light): + """Representation of an ISY994 light device.""" @property def is_on(self) -> bool: @@ -58,6 +44,7 @@ def turn_off(self, **kwargs) -> None: if not self._node.off(): _LOGGER.debug("Unable to turn off light") + # pylint: disable=arguments-differ def turn_on(self, brightness=None, **kwargs) -> None: """Send the turn on command to the ISY994 light device.""" if not self._node.on(val=brightness): diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 62261944febb6..18446951735bf 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -4,20 +4,24 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.knx/ """ -import asyncio + import voluptuous as vol -from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES -from homeassistant.components.light import PLATFORM_SCHEMA, Light, \ - SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS +from homeassistant.components.knx import ATTR_DISCOVER_DEVICES, DATA_KNX +from homeassistant.components.light import ( + 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' CONF_BRIGHTNESS_ADDRESS = 'brightness_address' CONF_BRIGHTNESS_STATE_ADDRESS = 'brightness_state_address' +CONF_COLOR_ADDRESS = 'color_address' +CONF_COLOR_STATE_ADDRESS = 'color_state_address' DEFAULT_NAME = 'KNX Light' DEPENDENCIES = ['knx'] @@ -28,38 +32,33 @@ vol.Optional(CONF_STATE_ADDRESS): cv.string, vol.Optional(CONF_BRIGHTNESS_ADDRESS): cv.string, vol.Optional(CONF_BRIGHTNESS_STATE_ADDRESS): cv.string, + vol.Optional(CONF_COLOR_ADDRESS): cv.string, + vol.Optional(CONF_COLOR_STATE_ADDRESS): cv.string, }) -@asyncio.coroutine -def async_setup_platform(hass, config, add_devices, - discovery_info=None): - """Set up light(s) for KNX platform.""" - if DATA_KNX not in hass.data \ - or not hass.data[DATA_KNX].initialized: - return False - +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up lights for KNX platform.""" if discovery_info is not None: - async_add_devices_discovery(hass, discovery_info, add_devices) + async_add_devices_discovery(hass, discovery_info, async_add_devices) else: - async_add_devices_config(hass, config, add_devices) - - return True + async_add_devices_config(hass, config, async_add_devices) @callback -def async_add_devices_discovery(hass, discovery_info, add_devices): +def async_add_devices_discovery(hass, discovery_info, async_add_devices): """Set up lights for KNX platform configured via xknx.yaml.""" entities = [] for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: device = hass.data[DATA_KNX].xknx.devices[device_name] entities.append(KNXLight(hass, device)) - add_devices(entities) + async_add_devices(entities) @callback -def async_add_devices_config(hass, config, add_devices): - """Set up light for KNX platform configured within plattform.""" +def async_add_devices_config(hass, config, async_add_devices): + """Set up light for KNX platform configured within platform.""" import xknx light = xknx.devices.Light( hass.data[DATA_KNX].xknx, @@ -68,16 +67,18 @@ def async_add_devices_config(hass, config, add_devices): group_address_switch_state=config.get(CONF_STATE_ADDRESS), group_address_brightness=config.get(CONF_BRIGHTNESS_ADDRESS), group_address_brightness_state=config.get( - CONF_BRIGHTNESS_STATE_ADDRESS)) + CONF_BRIGHTNESS_STATE_ADDRESS), + group_address_color=config.get(CONF_COLOR_ADDRESS), + group_address_color_state=config.get(CONF_COLOR_STATE_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(light) - add_devices([KNXLight(hass, light)]) + async_add_devices([KNXLight(hass, light)]) class KNXLight(Light): """Representation of a KNX light.""" def __init__(self, hass, device): - """Initialization of KNXLight.""" + """Initialize of KNX light.""" self.device = device self.hass = hass self.async_register_callbacks() @@ -85,11 +86,10 @@ def __init__(self, hass, device): @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): - """Callback after device was updated.""" + async def after_update_callback(device): + """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property @@ -97,6 +97,11 @@ def name(self): """Return the name of the KNX device.""" return self.device.name + @property + def available(self): + """Return True if entity is available.""" + return self.hass.data[DATA_KNX].connected + @property def should_poll(self): """No polling needed within KNX.""" @@ -105,18 +110,15 @@ def should_poll(self): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self.device.brightness \ - if self.device.supports_dimming else \ + return self.device.current_brightness \ + if self.device.supports_brightness else \ 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 color_util.color_RGB_to_hs(*self.device.current_color) return None @property @@ -148,19 +150,24 @@ def is_on(self): def supported_features(self): """Flag supported features.""" flags = 0 - if self.device.supports_dimming: + if self.device.supports_brightness: flags |= SUPPORT_BRIGHTNESS + if self.device.supports_color: + flags |= SUPPORT_COLOR return flags - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the light on.""" - if ATTR_BRIGHTNESS in kwargs and self.device.supports_dimming: - yield from self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + if ATTR_BRIGHTNESS in kwargs: + if self.device.supports_brightness: + await self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + elif ATTR_HS_COLOR in kwargs: + if self.device.supports_color: + await self.device.set_color(color_util.color_hs_to_RGB( + *kwargs[ATTR_HS_COLOR])) else: - yield from self.device.set_on() + await self.device.set_on() - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the light off.""" - yield from self.device.set_off() + await self.device.set_off() diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 6b57a1c514631..dff5ccd42acff 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -4,36 +4,33 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.lifx/ """ -import logging import asyncio -import sys -import math -from os import path -from functools import partial from datetime import timedelta +from functools import partial +import logging +import math +import sys import voluptuous as vol +from homeassistant import util from homeassistant.components.light import ( - Light, DOMAIN, PLATFORM_SCHEMA, LIGHT_TURN_ON_SCHEMA, - ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, ATTR_RGB_COLOR, - ATTR_XY_COLOR, ATTR_COLOR_TEMP, ATTR_KELVIN, ATTR_TRANSITION, ATTR_EFFECT, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, - SUPPORT_XY_COLOR, SUPPORT_TRANSITION, SUPPORT_EFFECT, - VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, ATTR_COLOR_TEMP, + 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.config import load_yaml_config_file from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP -from homeassistant import util from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.service import extract_entity_ids -import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiolifx==0.5.4', 'aiolifx_effects==0.1.1'] +REQUIREMENTS = ['aiolifx==0.6.1', 'aiolifx_effects==0.1.2'] UDP_BROADCAST_PORT = 56700 @@ -90,11 +87,22 @@ 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), @@ -126,8 +134,10 @@ def aiolifx_effects(): return aiolifx_effects_module -@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 LIFX platform.""" if sys.platform == 'win32': _LOGGER.warning("The lifx platform is known to not work on Windows. " @@ -157,20 +167,10 @@ def cleanup(event): return True -def lifxwhite(device): - """Return whether this is a white-only bulb.""" - features = aiolifx().products.features_map.get(device.product, None) - if features: - return not features["color"] - return False - - -def lifxmultizone(device): - """Return whether this is a multizone bulb/strip.""" - features = aiolifx().products.features_map.get(device.product, None) - if features: - return features["multizone"] - return False +def lifx_features(device): + """Return a feature map for this device, or a default map if unknown.""" + return aiolifx().products.features_map.get(device.product) or \ + aiolifx().products.features_map.get(1) def find_hsbk(**kwargs): @@ -179,16 +179,10 @@ 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]) - saturation = convert_8_to_16(saturation) - brightness = convert_8_to_16(brightness) - kelvin = 3500 - - if ATTR_XY_COLOR in kwargs: - hue, saturation = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) - saturation = convert_8_to_16(saturation) + if ATTR_HS_COLOR in kwargs: + hue, saturation = kwargs[ATTR_HS_COLOR] + hue = int(hue / 360 * 65535) + saturation = int(saturation / 100 * 65535) kelvin = 3500 if ATTR_COLOR_TEMP in kwargs: @@ -220,57 +214,47 @@ def __init__(self, hass, async_add_devices): self.async_add_devices = async_add_devices self.effects_conductor = aiolifx_effects().Conductor(loop=hass.loop) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - - self.register_set_state(descriptions) - self.register_effects(descriptions) + self.register_set_state() + self.register_effects() - def register_set_state(self, descriptions): + def register_set_state(self): """Register the LIFX set_state service call.""" - @asyncio.coroutine - def async_service_handle(service): + async def service_handler(service): """Apply a service.""" tasks = [] for light in self.service_to_entities(service): if service.service == SERVICE_LIFX_SET_STATE: - task = light.async_set_state(**service.data) + task = light.set_state(**service.data) tasks.append(self.hass.async_add_job(task)) if tasks: - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) self.hass.services.async_register( - DOMAIN, SERVICE_LIFX_SET_STATE, async_service_handle, - descriptions.get(SERVICE_LIFX_SET_STATE), + DOMAIN, SERVICE_LIFX_SET_STATE, service_handler, schema=LIFX_SET_STATE_SCHEMA) - def register_effects(self, descriptions): + def register_effects(self): """Register the LIFX effects as hass service calls.""" - @asyncio.coroutine - def async_service_handle(service): + async def service_handler(service): """Apply a service, i.e. start an effect.""" entities = self.service_to_entities(service) if entities: - yield from self.start_effect( + await self.start_effect( entities, service.service, **service.data) self.hass.services.async_register( - DOMAIN, SERVICE_EFFECT_PULSE, async_service_handle, - descriptions.get(SERVICE_EFFECT_PULSE), + DOMAIN, SERVICE_EFFECT_PULSE, service_handler, schema=LIFX_EFFECT_PULSE_SCHEMA) self.hass.services.async_register( - DOMAIN, SERVICE_EFFECT_COLORLOOP, async_service_handle, - descriptions.get(SERVICE_EFFECT_COLORLOOP), + DOMAIN, SERVICE_EFFECT_COLORLOOP, service_handler, schema=LIFX_EFFECT_COLORLOOP_SCHEMA) self.hass.services.async_register( - DOMAIN, SERVICE_EFFECT_STOP, async_service_handle, - descriptions.get(SERVICE_EFFECT_STOP), + DOMAIN, SERVICE_EFFECT_STOP, service_handler, schema=LIFX_EFFECT_STOP_SCHEMA) - @asyncio.coroutine - def start_effect(self, entities, service, **kwargs): + async def start_effect(self, entities, service, **kwargs): """Start a light effect on entities.""" devices = list(map(lambda l: l.device, entities)) @@ -282,7 +266,7 @@ def start_effect(self, entities, service, **kwargs): mode=kwargs.get(ATTR_MODE), hsbk=find_hsbk(**kwargs), ) - yield from self.effects_conductor.start(effect, devices) + await self.effects_conductor.start(effect, devices) elif service == SERVICE_EFFECT_COLORLOOP: preprocess_turn_on_alternatives(kwargs) @@ -298,9 +282,9 @@ def start_effect(self, entities, service, **kwargs): transition=kwargs.get(ATTR_TRANSITION), brightness=brightness, ) - yield from self.effects_conductor.start(effect, devices) + await self.effects_conductor.start(effect, devices) elif service == SERVICE_EFFECT_STOP: - yield from self.effects_conductor.stop(devices) + await self.effects_conductor.stop(devices) def service_to_entities(self, service): """Return the known devices that a service call mentions.""" @@ -315,25 +299,24 @@ def service_to_entities(self, service): @callback def register(self, device): - """Handler for newly detected bulb.""" - self.hass.async_add_job(self.async_register(device)) + """Handle aiolifx detected bulb.""" + self.hass.async_add_job(self.register_new_device(device)) - @asyncio.coroutine - def async_register(self, device): - """Handler for newly detected bulb.""" + async def register_new_device(self, device): + """Handle newly detected bulb.""" if device.mac_addr in self.entities: entity = self.entities[device.mac_addr] entity.registered = True _LOGGER.debug("%s register AGAIN", entity.who) - yield from entity.update_hass() + await entity.update_hass() else: _LOGGER.debug("%s register NEW", device.ip_addr) # Read initial state ack = AwaitAioLIFX().wait - version_resp = yield from ack(device.get_version) + version_resp = await ack(device.get_version) if version_resp: - color_resp = yield from ack(device.get_color) + color_resp = await ack(device.get_color) if version_resp is None or color_resp is None: _LOGGER.error("Failed to initialize %s", device.ip_addr) @@ -342,12 +325,12 @@ def async_register(self, device): device.retry_count = MESSAGE_RETRIES device.unregister_timeout = UNAVAILABLE_GRACE - if lifxwhite(device): - entity = LIFXWhite(device, self.effects_conductor) - elif lifxmultizone(device): + if lifx_features(device)["multizone"]: entity = LIFXStrip(device, self.effects_conductor) - else: + elif lifx_features(device)["color"]: entity = LIFXColor(device, self.effects_conductor) + else: + entity = LIFXWhite(device, self.effects_conductor) _LOGGER.debug("%s register READY", entity.who) self.entities[device.mac_addr] = entity @@ -355,7 +338,7 @@ def async_register(self, device): @callback def unregister(self, device): - """Handle disappearing bulbs.""" + """Handle aiolifx disappearing bulbs.""" if device.mac_addr in self.entities: entity = self.entities[device.mac_addr] _LOGGER.debug("%s unregister", entity.who) @@ -379,15 +362,14 @@ def callback(self, device, message): self.message = message self.event.set() - @asyncio.coroutine - def wait(self, method): + async def wait(self, method): """Call an aiolifx method and wait for its response.""" self.device = None self.message = None self.event.clear() method(callb=self.callback) - yield from self.event.wait() + await self.event.wait() return self.message @@ -417,6 +399,11 @@ def available(self): """Return the availability of the device.""" return self.registered + @property + def unique_id(self): + """Return a unique ID.""" + return self.device.mac_addr + @property def name(self): """Return the name of the device.""" @@ -427,6 +414,29 @@ def who(self): """Return a string identifying the device.""" return "%s (%s)" % (self.device.ip_addr, self.name) + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + kelvin = lifx_features(self.device)['max_kelvin'] + return math.floor(color_util.color_temperature_kelvin_to_mired(kelvin)) + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + kelvin = lifx_features(self.device)['min_kelvin'] + return math.ceil(color_util.color_temperature_kelvin_to_mired(kelvin)) + + @property + def supported_features(self): + """Flag supported features.""" + support = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_EFFECT + + device_features = lifx_features(self.device) + if device_features['min_kelvin'] != device_features['max_kelvin']: + support |= SUPPORT_COLOR_TEMP + + return support + @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -456,21 +466,19 @@ def effect(self): return 'lifx_effect_' + effect.name return None - @asyncio.coroutine - def update_hass(self, now=None): + async def update_hass(self, now=None): """Request new status and push it to hass.""" self.postponed_update = None - yield from self.async_update() - yield from self.async_update_ha_state() + await self.async_update() + await self.async_update_ha_state() - @asyncio.coroutine - def update_during_transition(self, when): + async def update_during_transition(self, when): """Update state at the start and end of a transition.""" if self.postponed_update: self.postponed_update() # Transition has started - yield from self.update_hass() + await self.update_hass() # Transition has ended if when > 0: @@ -478,28 +486,25 @@ def update_during_transition(self, when): self.hass, self.update_hass, util.dt.utcnow() + timedelta(milliseconds=when)) - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" kwargs[ATTR_POWER] = True - self.hass.async_add_job(self.async_set_state(**kwargs)) + self.hass.async_add_job(self.set_state(**kwargs)) - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" kwargs[ATTR_POWER] = False - self.hass.async_add_job(self.async_set_state(**kwargs)) + self.hass.async_add_job(self.set_state(**kwargs)) - @asyncio.coroutine - def async_set_state(self, **kwargs): + async def set_state(self, **kwargs): """Set a color on the light and turn it on/off.""" - with (yield from self.lock): + async with self.lock: bulb = self.device - yield from self.effects_conductor.stop([bulb]) + await self.effects_conductor.stop([bulb]) if ATTR_EFFECT in kwargs: - yield from self.default_effect(**kwargs) + await self.default_effect(**kwargs) return if ATTR_INFRARED in kwargs: @@ -521,72 +526,52 @@ def async_set_state(self, **kwargs): if not self.is_on: if power_off: - yield from self.set_power(ack, False) + await self.set_power(ack, False) if hsbk: - yield from self.set_color(ack, hsbk, kwargs) + await self.set_color(ack, hsbk, kwargs) if power_on: - yield from self.set_power(ack, True, duration=fade) + await self.set_power(ack, True, duration=fade) else: if power_on: - yield from self.set_power(ack, True) + await self.set_power(ack, True) if hsbk: - yield from self.set_color(ack, hsbk, kwargs, duration=fade) + await self.set_color(ack, hsbk, kwargs, duration=fade) if power_off: - yield from self.set_power(ack, False, duration=fade) + await self.set_power(ack, False, duration=fade) # Avoid state ping-pong by holding off updates as the state settles - yield from asyncio.sleep(0.3) + await asyncio.sleep(0.3) # Update when the transition starts and ends - yield from self.update_during_transition(fade) + await self.update_during_transition(fade) - @asyncio.coroutine - def set_power(self, ack, pwr, duration=0): + async def set_power(self, ack, pwr, duration=0): """Send a power change to the device.""" - yield from ack(partial(self.device.set_power, pwr, duration=duration)) + await ack(partial(self.device.set_power, pwr, duration=duration)) - @asyncio.coroutine - def set_color(self, ack, hsbk, kwargs, duration=0): + async def set_color(self, ack, hsbk, kwargs, duration=0): """Send a color change to the device.""" hsbk = merge_hsbk(self.device.color, hsbk) - yield from ack(partial(self.device.set_color, hsbk, duration=duration)) + await ack(partial(self.device.set_color, hsbk, duration=duration)) - @asyncio.coroutine - def default_effect(self, **kwargs): + async def default_effect(self, **kwargs): """Start an effect with default parameters.""" service = kwargs[ATTR_EFFECT] data = { ATTR_ENTITY_ID: self.entity_id, } - yield from self.hass.services.async_call(DOMAIN, service, data) + await self.hass.services.async_call(DOMAIN, service, data) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update bulb status.""" _LOGGER.debug("%s async_update", self.who) if self.available and not self.lock.locked(): - yield from AwaitAioLIFX().wait(self.device.get_color) + await AwaitAioLIFX().wait(self.device.get_color) class LIFXWhite(LIFXLight): """Representation of a white-only LIFX light.""" - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return math.floor(color_util.color_temperature_kelvin_to_mired(6500)) - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return math.ceil(color_util.color_temperature_kelvin_to_mired(2700)) - - @property - def supported_features(self): - """Flag supported features.""" - return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION | - SUPPORT_EFFECT) - @property def effect_list(self): """Return the list of supported effects for this light.""" @@ -599,21 +584,12 @@ def effect_list(self): class LIFXColor(LIFXLight): """Representation of a color LIFX light.""" - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return math.floor(color_util.color_temperature_kelvin_to_mired(9000)) - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return math.ceil(color_util.color_temperature_kelvin_to_mired(2500)) - @property def supported_features(self): """Flag supported features.""" - return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION | - SUPPORT_EFFECT | SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR) + support = super().supported_features + support |= SUPPORT_COLOR + return support @property def effect_list(self): @@ -625,37 +601,42 @@ def effect_list(self): ] @property - def rgb_color(self): - """Return the RGB value.""" - hue, sat, bri, _ = self.device.color - - return color_util.color_hsv_to_RGB( - hue, convert_16_to_8(sat), convert_16_to_8(bri)) + def hs_color(self): + """Return the hs value.""" + hue, sat, _, _ = self.device.color + hue = hue / 65535 * 360 + sat = sat / 65535 * 100 + return (hue, sat) class LIFXStrip(LIFXColor): """Representation of a LIFX light strip with multiple zones.""" - @asyncio.coroutine - def set_color(self, ack, hsbk, kwargs, duration=0): + async def set_color(self, ack, hsbk, kwargs, duration=0): """Send a color change to the device.""" bulb = self.device num_zones = len(bulb.color_zones) - # Zone brightness is not reported when powered off - if not self.is_on and hsbk[2] is None: - yield from self.set_power(ack, True) - yield from asyncio.sleep(0.3) - yield from self.update_color_zones() - yield from self.set_power(ack, False) - yield from asyncio.sleep(0.3) - - zones = kwargs.get(ATTR_ZONES, None) + zones = kwargs.get(ATTR_ZONES) if zones is None: + # Fast track: setting all zones to the same brightness and color + # can be treated as a single-zone bulb. + if hsbk[2] is not None and hsbk[3] is not None: + await super().set_color(ack, hsbk, kwargs, duration) + return + zones = list(range(0, num_zones)) else: zones = list(filter(lambda x: x < num_zones, set(zones))) + # Zone brightness is not reported when powered off + if not self.is_on and hsbk[2] is None: + await self.set_power(ack, True) + await asyncio.sleep(0.3) + await self.update_color_zones() + await self.set_power(ack, False) + await asyncio.sleep(0.3) + # Send new color to each zone for index, zone in enumerate(zones): zone_hsbk = merge_hsbk(bulb.color_zones[zone], hsbk) @@ -666,26 +647,23 @@ def set_color(self, ack, hsbk, kwargs, duration=0): color=zone_hsbk, duration=duration, apply=apply) - yield from ack(set_zone) + await ack(set_zone) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update strip status.""" if self.available and not self.lock.locked(): - yield from super().async_update() - yield from self.update_color_zones() + await super().async_update() + await self.update_color_zones() - @asyncio.coroutine - def update_color_zones(self): + async def update_color_zones(self): """Get updated color information for each zone.""" zone = 0 top = 1 while self.available and zone < top: # Each get_color_zones can update 8 zones at once - resp = yield from AwaitAioLIFX().wait(partial( + resp = await AwaitAioLIFX().wait(partial( self.device.get_color_zones, - start_index=zone, - end_index=zone+7)) + start_index=zone)) if resp: zone += 8 top = resp.count diff --git a/homeassistant/components/light/lifx_legacy.py b/homeassistant/components/light/lifx_legacy.py index cc48f4cf4c15c..490eeb6ecaba1 100644 --- a/homeassistant/components/light/lifx_legacy.py +++ b/homeassistant/components/light/lifx_legacy.py @@ -7,14 +7,13 @@ 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,12 +36,12 @@ 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({ - vol.Optional(CONF_SERVER, default=None): cv.string, - vol.Optional(CONF_BROADCAST, default=None): cv.string, + vol.Optional(CONF_SERVER): cv.string, + vol.Optional(CONF_BROADCAST): cv.string, }) @@ -129,17 +128,6 @@ def probe(self, address=None): 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 @@ def ipaddr(self): 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 @@ def turn_on(self, **kwargs): 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 @@ def set_color(self, hue, sat, bri, kel): 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 aad2abdd183dc..bb84b3a6fed02 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -4,19 +4,24 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.limitlessled/ """ +import asyncio import logging import voluptuous as vol -from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_PORT, CONF_TYPE) +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, color_hs_to_RGB) +from homeassistant.helpers.restore_state import async_get_last_state -REQUIREMENTS = ['limitlessled==1.0.8'] +REQUIREMENTS = ['limitlessled==1.1.0'] _LOGGER = logging.getLogger(__name__) @@ -32,20 +37,23 @@ DEFAULT_VERSION = 6 DEFAULT_FADE = False -LED_TYPE = ['rgbw', 'rgbww', 'white', 'bridge-led'] +LED_TYPE = ['rgbw', 'rgbww', 'white', 'bridge-led', 'dimmer'] -RGB_BOUNDARY = 40 +EFFECT_NIGHT = 'night' -WHITE = [255, 255, 255] +MIN_SATURATION = 10 + +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, [ @@ -115,7 +123,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): group_conf.get(CONF_NUMBER), group_conf.get(CONF_NAME), group_conf.get(CONF_TYPE, DEFAULT_LED_TYPE)) - lights.append(LimitlessLEDGroup.factory(group, { + lights.append(LimitlessLEDGroup(group, { 'fade': group_conf[CONF_FADE] })) add_devices(lights) @@ -138,9 +146,6 @@ def wrapper(self, **kwargs): if self.repeating: self.repeating = False self.group.stop() - # Not on and should be? Turn on. - if not self.is_on and new_state is True: - pipeline.on() # Set transition time. if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) @@ -159,30 +164,51 @@ class LimitlessLEDGroup(Light): def __init__(self, group, config): """Initialize a group.""" - self.group = group - self.repeating = False - self._is_on = False - self._brightness = None - self.config = config - - @staticmethod - def factory(group, config): - """Produce LimitlessLEDGroup objects.""" from limitlessled.group.rgbw import RgbwGroup from limitlessled.group.white import WhiteGroup + from limitlessled.group.dimmer import DimmerGroup from limitlessled.group.rgbww import RgbwwGroup if isinstance(group, WhiteGroup): - return LimitlessLEDWhiteGroup(group, config) + self._supported = SUPPORT_LIMITLESSLED_WHITE + self._effect_list = [EFFECT_NIGHT] + elif isinstance(group, DimmerGroup): + self._supported = SUPPORT_LIMITLESSLED_DIMMER + self._effect_list = [] elif isinstance(group, RgbwGroup): - return LimitlessLEDRGBWGroup(group, config) + self._supported = SUPPORT_LIMITLESSLED_RGB + self._effect_list = [EFFECT_COLORLOOP, EFFECT_NIGHT, EFFECT_WHITE] elif isinstance(group, RgbwwGroup): - return LimitlessLEDRGBWWGroup(group, config) + self._supported = SUPPORT_LIMITLESSLED_RGBWW + self._effect_list = [EFFECT_COLORLOOP, EFFECT_NIGHT, EFFECT_WHITE] + + self.group = group + self.config = config + self.repeating = False + self._is_on = False + self._brightness = None + self._temperature = None + self._color = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Called when entity is about to be added to hass.""" + last_state = yield from async_get_last_state(self.hass, self.entity_id) + if last_state: + 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('hs_color') @property def should_poll(self): """No polling needed.""" return False + @property + def assumed_state(self): + """Return True because unable to access real state of the entity.""" + return True + @property def name(self): """Return the name of the group.""" @@ -198,28 +224,15 @@ def brightness(self): """Return the brightness property.""" return self._brightness - @state(False) - def turn_off(self, transition_time, pipeline, **kwargs): - """Turn off a group.""" - if self.is_on: - if self.config[CONF_FADE]: - pipeline.transition(transition_time, brightness=0.0) - pipeline.off() - - -class LimitlessLEDWhiteGroup(LimitlessLEDGroup): - """Representation of a LimitlessLED White group.""" + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 154 - def __init__(self, group, config): - """Initialize White group.""" - super().__init__(group, config) - # Initialize group with known values. - self.group.on = True - self.group.temperature = 1.0 - self.group.brightness = 0.0 - self._brightness = _to_hass_brightness(1.0) - self._temperature = _to_hass_temperature(self.group.temperature) - self.group.on = False + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 370 @property def color_temp(self): @@ -227,186 +240,100 @@ def color_temp(self): return self._temperature @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_LIMITLESSLED_WHITE - - @state(True) - def turn_on(self, transition_time, pipeline, **kwargs): - """Turn on (or adjust property of) a group.""" - # Check arguments. - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_COLOR_TEMP in kwargs: - self._temperature = kwargs[ATTR_COLOR_TEMP] - # Set up transition. - pipeline.transition( - transition_time, - brightness=_from_hass_brightness(self._brightness), - temperature=_from_hass_temperature(self._temperature) - ) - - -class LimitlessLEDRGBWGroup(LimitlessLEDGroup): - """Representation of a LimitlessLED RGBW group.""" - - def __init__(self, group, config): - """Initialize RGBW group.""" - super().__init__(group, config) - # Initialize group with known values. - self.group.on = True - self.group.white() - self._color = WHITE - self.group.brightness = 0.0 - self._brightness = _to_hass_brightness(1.0) - self.group.on = False - - @property - def rgb_color(self): + def hs_color(self): """Return the color property.""" return self._color @property def supported_features(self): """Flag supported features.""" - return SUPPORT_LIMITLESSLED_RGB + return self._supported + + @property + def effect_list(self): + """Return the list of supported effects for this light.""" + return self._effect_list + + # pylint: disable=arguments-differ + @state(False) + def turn_off(self, transition_time, pipeline, **kwargs): + """Turn off a group.""" + if self.config[CONF_FADE]: + pipeline.transition(transition_time, brightness=0.0) + pipeline.off() + # pylint: disable=arguments-differ @state(True) def turn_on(self, transition_time, pipeline, **kwargs): """Turn on (or adjust property of) a group.""" - from limitlessled.presets import COLORLOOP - # Check arguments. - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_RGB_COLOR in kwargs: - self._color = kwargs[ATTR_RGB_COLOR] - # White is a special case. - if min(self._color) > 256 - RGB_BOUNDARY: - pipeline.white() - self._color = WHITE - # Set up transition. - pipeline.transition( - transition_time, - brightness=_from_hass_brightness(self._brightness), - color=_from_hass_color(self._color) - ) - # Flash. - if ATTR_FLASH in kwargs: - duration = 0 - if kwargs[ATTR_FLASH] == FLASH_LONG: - duration = 1 - pipeline.flash(duration=duration) - # Add effects. - if ATTR_EFFECT in kwargs: - if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: - self.repeating = True - pipeline.append(COLORLOOP) - if kwargs[ATTR_EFFECT] == EFFECT_WHITE: - pipeline.white() - self._color = WHITE + # The night effect does not need a turned on light + if kwargs.get(ATTR_EFFECT) == EFFECT_NIGHT: + if EFFECT_NIGHT in self._effect_list: + pipeline.night_light() + return + pipeline.on() -class LimitlessLEDRGBWWGroup(LimitlessLEDGroup): - """Representation of a LimitlessLED RGBWW group.""" + # Set up transition. + args = {} + if self.config[CONF_FADE] and not self.is_on and self._brightness: + args['brightness'] = self.limitlessled_brightness() - def __init__(self, group, config): - """Initialize RGBWW group.""" - super().__init__(group, config) - # Initialize group with known values. - self.group.on = True - self.group.white() - self.group.temperature = 0.0 - self._color = WHITE - self.group.brightness = 0.0 - self._brightness = _to_hass_brightness(1.0) - self._temperature = _to_hass_temperature(self.group.temperature) - self.group.on = False + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] + args['brightness'] = self.limitlessled_brightness() - @property - def rgb_color(self): - """Return the color property.""" - return self._color + if ATTR_HS_COLOR in kwargs and self._supported & SUPPORT_COLOR: + self._color = kwargs[ATTR_HS_COLOR] + # White is a special case. + if self._color[1] < MIN_SATURATION: + pipeline.white() + self._color = WHITE + else: + args['color'] = self.limitlessled_color() - @property - def color_temp(self): - """Return the temperature property.""" - return self._temperature + if ATTR_COLOR_TEMP in kwargs: + if self._supported & SUPPORT_COLOR: + pipeline.white() + self._color = WHITE + if self._supported & SUPPORT_COLOR_TEMP: + self._temperature = kwargs[ATTR_COLOR_TEMP] + args['temperature'] = self.limitlessled_temperature() - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_LIMITLESSLED_RGBWW + if args: + pipeline.transition(transition_time, **args) - @state(True) - def turn_on(self, transition_time, pipeline, **kwargs): - """Turn on (or adjust property of) a group.""" - from limitlessled.presets import COLORLOOP - # Check arguments. - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_RGB_COLOR in kwargs: - self._color = kwargs[ATTR_RGB_COLOR] - elif ATTR_COLOR_TEMP in kwargs: - self._temperature = kwargs[ATTR_COLOR_TEMP] - # White is a special case. - if min(self._color) > 256 - RGB_BOUNDARY: - pipeline.white() - self._color = WHITE - # Set up transition. - if self._color == WHITE: - pipeline.transition( - transition_time, - brightness=_from_hass_brightness(self._brightness), - temperature=_from_hass_temperature(self._temperature) - ) - else: - pipeline.transition( - transition_time, - brightness=_from_hass_brightness(self._brightness), - color=_from_hass_color(self._color) - ) # Flash. - if ATTR_FLASH in kwargs: + if ATTR_FLASH in kwargs and self._supported & SUPPORT_FLASH: duration = 0 if kwargs[ATTR_FLASH] == FLASH_LONG: duration = 1 pipeline.flash(duration=duration) + # Add effects. - if ATTR_EFFECT in kwargs: + if ATTR_EFFECT in kwargs and self._effect_list: if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: + from limitlessled.presets import COLORLOOP self.repeating = True pipeline.append(COLORLOOP) if kwargs[ATTR_EFFECT] == EFFECT_WHITE: pipeline.white() self._color = WHITE - -def _from_hass_temperature(temperature): - """Convert Home Assistant color temperature units to percentage.""" - return 1 - (temperature - 154) / 346 - - -def _to_hass_temperature(temperature): - """Convert percentage to Home Assistant color temperature units.""" - return 500 - int(temperature * 346) - - -def _from_hass_brightness(brightness): - """Convert Home Assistant brightness units to percentage.""" - return brightness / 255 - - -def _to_hass_brightness(brightness): - """Convert percentage to Home Assistant brightness units.""" - return int(brightness * 255) - - -def _from_hass_color(color): - """Convert Home Assistant RGB list to Color tuple.""" - from limitlessled import Color - return Color(*tuple(color)) - - -def _to_hass_color(color): - """Convert from Color tuple to Home Assistant RGB list.""" - return list([int(c) for c in color]) + def limitlessled_temperature(self): + """Convert Home Assistant color temperature units to percentage.""" + max_kelvin = color_temperature_mired_to_kelvin(self.min_mireds) + min_kelvin = color_temperature_mired_to_kelvin(self.max_mireds) + width = max_kelvin - min_kelvin + kelvin = color_temperature_mired_to_kelvin(self._temperature) + temperature = (kelvin - min_kelvin) / width + return max(0, min(1, temperature)) + + def limitlessled_brightness(self): + """Convert Home Assistant brightness units to percentage.""" + return self._brightness / 255 + + def limitlessled_color(self): + """Convert Home Assistant HS list to RGB Color tuple.""" + from limitlessled import Color + return Color(*color_hs_to_RGB(*tuple(self._color))) diff --git a/homeassistant/components/light/lutron_caseta.py b/homeassistant/components/light/lutron_caseta.py index c11b3da6f750f..e4e1baf6c582d 100644 --- a/homeassistant/components/light/lutron_caseta.py +++ b/homeassistant/components/light/lutron_caseta.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.lutron_caseta/ """ +import asyncio import logging from homeassistant.components.light import ( @@ -19,7 +20,8 @@ # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Lutron Caseta lights.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -28,7 +30,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = LutronCasetaLight(light_device, bridge) devs.append(dev) - add_devices(devs, True) + async_add_devices(devs, True) class LutronCasetaLight(LutronCasetaDevice, Light): @@ -44,7 +46,8 @@ def brightness(self): """Return the brightness of the light.""" return to_hass_level(self._state["current_state"]) - def turn_on(self, **kwargs): + @asyncio.coroutine + def async_turn_on(self, **kwargs): """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] @@ -53,7 +56,8 @@ def turn_on(self, **kwargs): self._smartbridge.set_value(self._device_id, to_lutron_level(brightness)) - def turn_off(self, **kwargs): + @asyncio.coroutine + def async_turn_off(self, **kwargs): """Turn the light off.""" self._smartbridge.set_value(self._device_id, 0) @@ -62,7 +66,8 @@ def is_on(self): """Return true if device is on.""" return self._state["current_state"] > 0 - def update(self): + @asyncio.coroutine + def async_update(self): """Call when forcing a refresh of the device.""" self._state = self._smartbridge.get_device_by_id(self._device_id) _LOGGER.debug(self._state) diff --git a/homeassistant/components/light/mochad.py b/homeassistant/components/light/mochad.py index fffaa293188f9..576e244103f6e 100644 --- a/homeassistant/components/light/mochad.py +++ b/homeassistant/components/light/mochad.py @@ -12,13 +12,15 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, PLATFORM_SCHEMA) from homeassistant.components import mochad -from homeassistant.const import (CONF_NAME, CONF_PLATFORM, CONF_DEVICES, - CONF_ADDRESS) +from homeassistant.const import ( + CONF_NAME, CONF_PLATFORM, CONF_DEVICES, CONF_ADDRESS) from homeassistant.helpers import config_validation as cv DEPENDENCIES = ['mochad'] _LOGGER = logging.getLogger(__name__) +CONF_BRIGHTNESS_LEVELS = 'brightness_levels' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): mochad.DOMAIN, @@ -26,6 +28,8 @@ vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): cv.x10_address, vol.Optional(mochad.CONF_COMM_TYPE): cv.string, + vol.Optional(CONF_BRIGHTNESS_LEVELS, default=32): + vol.All(vol.Coerce(int), vol.In([32, 64, 256])), }] }) @@ -54,15 +58,17 @@ def __init__(self, hass, ctrl, dev): comm_type=self._comm_type) self._brightness = 0 self._state = self._get_device_status() + self._brightness_levels = dev.get(CONF_BRIGHTNESS_LEVELS) - 1 @property def brightness(self): - """Return the birghtness of this light between 0..255.""" + """Return the brightness of this light between 0..255.""" return self._brightness def _get_device_status(self): """Get the status of the light from mochad.""" - status = self.device.get_status().rstrip() + with mochad.REQ_LOCK: + status = self.device.get_status().rstrip() return status == 'on' @property @@ -85,15 +91,47 @@ def assumed_state(self): """X10 devices are normally 1-way so we have to assume the state.""" return True + def _calculate_brightness_value(self, value): + return int(value * (float(self._brightness_levels) / 255.0)) + + def _adjust_brightness(self, brightness): + if self._brightness > brightness: + bdelta = self._brightness - brightness + mochad_brightness = self._calculate_brightness_value(bdelta) + self.device.send_cmd("dim {}".format(mochad_brightness)) + self._controller.read_data() + elif self._brightness < brightness: + bdelta = brightness - self._brightness + mochad_brightness = self._calculate_brightness_value(bdelta) + self.device.send_cmd("bright {}".format(mochad_brightness)) + self._controller.read_data() + def turn_on(self, **kwargs): """Send the command to turn the light on.""" - self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) - self.device.send_cmd("xdim {}".format(self._brightness)) - self._controller.read_data() + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + with mochad.REQ_LOCK: + if self._brightness_levels > 32: + out_brightness = self._calculate_brightness_value(brightness) + self.device.send_cmd('xdim {}'.format(out_brightness)) + self._controller.read_data() + else: + self.device.send_cmd("on") + self._controller.read_data() + # There is no persistence for X10 modules so a fresh on command + # will be full brightness + if self._brightness == 0: + self._brightness = 255 + self._adjust_brightness(brightness) + self._brightness = brightness self._state = True def turn_off(self, **kwargs): """Send the command to turn the light on.""" - self.device.send_cmd('off') - self._controller.read_data() + with mochad.REQ_LOCK: + self.device.send_cmd('off') + self._controller.read_data() + # There is no persistence for X10 modules so we need to prepare + # to track a fresh on command will full brightness + if self._brightness_levels == 31: + self._brightness = 0 self._state = False diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index ac72a7052f11b..97a4cc8c137ea 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -4,7 +4,6 @@ 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,17 +11,20 @@ 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_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC) + 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__) @@ -51,6 +53,7 @@ CONF_WHITE_VALUE_SCALE = 'white_value_scale' CONF_WHITE_VALUE_STATE_TOPIC = 'white_value_state_topic' CONF_WHITE_VALUE_TEMPLATE = 'white_value_template' +CONF_ON_COMMAND_TYPE = 'on_command_type' DEFAULT_BRIGHTNESS_SCALE = 255 DEFAULT_NAME = 'MQTT Light' @@ -58,6 +61,9 @@ DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_WHITE_VALUE_SCALE = 255 +DEFAULT_ON_COMMAND_TYPE = 'last' + +VALUES_ON_COMMAND_TYPE = ['first', 'last', 'brightness'] PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BRIGHTNESS_COMMAND_TOPIC): mqtt.valid_publish_topic, @@ -89,11 +95,13 @@ vol.Optional(CONF_XY_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_XY_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_XY_VALUE_TEMPLATE): cv.template, -}) + vol.Optional(CONF_ON_COMMAND_TYPE, default=DEFAULT_ON_COMMAND_TYPE): + vol.In(VALUES_ON_COMMAND_TYPE), +}).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) @@ -141,16 +149,23 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_OPTIMISTIC), config.get(CONF_BRIGHTNESS_SCALE), config.get(CONF_WHITE_VALUE_SCALE), + config.get(CONF_ON_COMMAND_TYPE), + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), )]) -class MqttLight(Light): +class MqttLight(MqttAvailability, Light): """Representation of a MQTT light.""" def __init__(self, name, effect_list, topic, templates, qos, retain, payload, optimistic, brightness_scale, - white_value_scale): + white_value_scale, on_command_type, availability_topic, + payload_available, payload_not_available): """Initialize MQTT light.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._name = name self._effect_list = effect_list self._topic = topic @@ -173,16 +188,16 @@ def __init__(self, name, effect_list, topic, templates, qos, optimistic or topic[CONF_XY_STATE_TOPIC] is None self._brightness_scale = brightness_scale self._white_value_scale = white_value_scale + 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) @@ -196,14 +211,12 @@ def __init__(self, name, effect_list, topic, templates, qos, 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): - """Subscribe to MQTT events. + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await super().async_added_to_hass() - This method is a coroutine. - """ templates = {} for key, tpl in list(self._templates.items()): if tpl is None: @@ -212,6 +225,8 @@ def async_added_to_hass(self): 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.""" @@ -220,12 +235,14 @@ def state_received(topic, payload, qos): self._state = True elif payload == self._payload['off']: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + 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): @@ -233,13 +250,16 @@ def brightness_received(topic, payload, qos): device_value = float(templates[CONF_BRIGHTNESS](payload)) percent_bright = device_value / self._brightness_scale self._brightness = int(percent_bright * 255) - self.hass.async_add_job(self.async_update_ha_state()) + 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: @@ -248,32 +268,37 @@ def brightness_received(topic, payload, qos): @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(',')] - self.hass.async_add_job(self.async_update_ha_state()) + 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): """Handle new MQTT messages for color temperature.""" self._color_temp = int(templates[CONF_COLOR_TEMP](payload)) - self.hass.async_add_job(self.async_update_ha_state()) + 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 @@ -282,14 +307,17 @@ def color_temp_received(topic, payload, qos): def effect_received(topic, payload, qos): """Handle new MQTT messages for effect.""" self._effect = templates[CONF_EFFECT](payload) - self.hass.async_add_job(self.async_update_ha_state()) + 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 @@ -300,13 +328,16 @@ def white_value_received(topic, payload, qos): device_value = float(templates[CONF_WHITE_VALUE](payload)) percent_white = device_value / self._white_value_scale self._white_value = int(percent_white * 255) - self.hass.async_add_job(self.async_update_ha_state()) + 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: @@ -315,19 +346,21 @@ def white_value_received(topic, payload, qos): @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.hass.async_add_job(self.async_update_ha_state()) + 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): @@ -335,9 +368,9 @@ def brightness(self): 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): @@ -349,11 +382,6 @@ def white_value(self): """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.""" @@ -389,31 +417,64 @@ def supported_features(self): """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. """ should_update = False - if ATTR_RGB_COLOR in kwargs and \ + if self._on_command_type == 'first': + mqtt.async_publish( + self.hass, self._topic[CONF_COMMAND_TOPIC], + self._payload['on'], self._qos, self._retain) + should_update = True + + # If brightness is being used instead of an on command, make sure + # there is a brightness input. Either set the brightness to our + # saved value or the maximum value if this is the first call + elif self._on_command_type == 'brightness': + if ATTR_BRIGHTNESS not in kwargs: + kwargs[ATTR_BRIGHTNESS] = self._brightness if \ + self._brightness else 255 + + 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 \ @@ -434,6 +495,7 @@ def async_turn_on(self, **kwargs): mqtt.async_publish( self.hass, self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC], color_temp, self._qos, self._retain) + if self._optimistic_color_temp: self._color_temp = kwargs[ATTR_COLOR_TEMP] should_update = True @@ -445,6 +507,7 @@ def async_turn_on(self, **kwargs): mqtt.async_publish( self.hass, self._topic[CONF_EFFECT_COMMAND_TOPIC], effect, self._qos, self._retain) + if self._optimistic_effect: self._effect = kwargs[ATTR_EFFECT] should_update = True @@ -461,21 +524,10 @@ def async_turn_on(self, **kwargs): 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 - - mqtt.async_publish( - self.hass, self._topic[CONF_COMMAND_TOPIC], self._payload['on'], - self._qos, self._retain) + if self._on_command_type == 'last': + mqtt.async_publish(self.hass, self._topic[CONF_COMMAND_TOPIC], + self._payload['on'], self._qos, self._retain) + should_update = True if self._optimistic: # Optimistically assume that switch has changed state. @@ -483,10 +535,9 @@ def async_turn_on(self, **kwargs): should_update = True if should_update: - self.hass.async_add_job(self.async_update_ha_state()) + 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. @@ -498,4 +549,4 @@ def async_turn_off(self, **kwargs): if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py old mode 100755 new mode 100644 index 4fee11389096d..14f5ee7a9b914 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -4,7 +4,6 @@ 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,16 +12,22 @@ 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_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) + 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__) @@ -40,15 +45,20 @@ 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({ vol.Optional(CONF_BRIGHTNESS, default=DEFAULT_BRIGHTNESS): cv.boolean, + vol.Optional(CONF_BRIGHTNESS_SCALE, default=DEFAULT_BRIGHTNESS_SCALE): + vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_COLOR_TEMP, default=DEFAULT_COLOR_TEMP): cv.boolean, vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECT): cv.boolean, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), @@ -65,12 +75,13 @@ 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) @@ -92,22 +103,30 @@ 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, CONF_FLASH_TIME_LONG ) - } + }, + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), + config.get(CONF_BRIGHTNESS_SCALE) )]) -class MqttJson(Light): +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, - flash_times): + brightness, color_temp, effect, rgb, white_value, xy, hs, + flash_times, availability_topic, payload_available, + payload_not_available, brightness_scale): """Initialize MQTT JSON light.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._name = name self._effect_list = effect_list self._topic = topic @@ -115,6 +134,9 @@ def __init__(self, name, effect_list, topic, qos, retain, optimistic, 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: @@ -130,37 +152,34 @@ def __init__(self, name, effect_list, topic, qos, retain, optimistic, 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): - """Subscribe to MQTT events. + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await super().async_added_to_hass() + + last_state = await async_get_last_state(self.hass, self.entity_id) - This method is a coroutine. - """ @callback def state_received(topic, payload, qos): """Handle new MQTT messages.""" @@ -171,21 +190,43 @@ def state_received(topic, payload, qos): 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']) + self._brightness = int(values['brightness'] / + float(self._brightness_scale) * + 255) except KeyError: pass except ValueError: @@ -213,26 +254,28 @@ def state_received(topic, payload, qos): except KeyError: pass except ValueError: - _LOGGER.warning("Invalid white value 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") + _LOGGER.warning("Invalid white value received") - self.hass.async_add_job(self.async_update_ha_state()) + 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.""" @@ -254,20 +297,15 @@ def effect_list(self): 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.""" @@ -293,8 +331,7 @@ def supported_features(self): """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. @@ -303,15 +340,29 @@ def async_turn_on(self, **kwargs): 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: @@ -326,7 +377,9 @@ def async_turn_on(self, **kwargs): message['transition'] = int(kwargs[ATTR_TRANSITION]) if ATTR_BRIGHTNESS in kwargs: - message['brightness'] = int(kwargs[ATTR_BRIGHTNESS]) + message['brightness'] = int(kwargs[ATTR_BRIGHTNESS] / + float(DEFAULT_BRIGHTNESS_SCALE) * + self._brightness_scale) if self._optimistic: self._brightness = kwargs[ATTR_BRIGHTNESS] @@ -353,16 +406,6 @@ def async_turn_on(self, **kwargs): 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) @@ -373,10 +416,9 @@ def async_turn_on(self, **kwargs): should_update = True if should_update: - self.hass.async_add_job(self.async_update_ha_state()) + 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. @@ -393,4 +435,4 @@ def async_turn_off(self, **kwargs): if self._optimistic: # Optimistically assume that the light has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt_template.py old mode 100755 new mode 100644 index 07fd6d45d8c75..e32c13fc5b6ef --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt_template.py @@ -4,7 +4,6 @@ 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,13 +11,17 @@ 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_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) + 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__) @@ -60,11 +63,11 @@ vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_QOS, default=mqtt.DEFAULT_QOS): vol.All(vol.Coerce(int), vol.In([0, 1, 2])), -}) +}).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) @@ -95,16 +98,22 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): }, config.get(CONF_OPTIMISTIC), config.get(CONF_QOS), - config.get(CONF_RETAIN) + config.get(CONF_RETAIN), + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), )]) -class MqttTemplate(Light): +class MqttTemplate(MqttAvailability, Light): """Representation of a MQTT Template light.""" def __init__(self, hass, name, effect_list, topics, templates, optimistic, - qos, retain): + qos, retain, availability_topic, payload_available, + payload_not_available): """Initialize a MQTT Template light.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._name = name self._effect_list = effect_list self._topics = topics @@ -134,21 +143,21 @@ def __init__(self, hass, name, effect_list, topics, templates, optimistic, 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): - """Subscribe to MQTT events. + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await super().async_added_to_hass() + + last_state = await async_get_last_state(self.hass, self.entity_id) - This method is a coroutine. - """ @callback def state_received(topic, payload, qos): """Handle new MQTT messages.""" @@ -179,17 +188,18 @@ def state_received(topic, payload, qos): 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") @@ -211,13 +221,26 @@ def state_received(topic, payload, qos): else: _LOGGER.warning("Unsupported effect value received") - self.hass.async_add_job(self.async_update_ha_state()) + 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.""" @@ -229,9 +252,9 @@ def color_temp(self): 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): @@ -271,8 +294,7 @@ def effect(self): """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. @@ -293,13 +315,18 @@ def async_turn_on(self, **kwargs): 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]) @@ -323,10 +350,9 @@ def async_turn_on(self, **kwargs): ) if self._optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + 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. @@ -345,7 +371,7 @@ def async_turn_off(self, **kwargs): ) if self._optimistic: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def supported_features(self): @@ -353,8 +379,8 @@ def supported_features(self): 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 c41f480c67e19..55387288d7f23 100644 --- a/homeassistant/components/light/mysensors.py +++ b/homeassistant/components/light/mysensors.py @@ -6,17 +6,18 @@ """ 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): - """Setup 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 @@ def __init__(self, *args): super().__init__(*args) self._state = None self._brightness = None - self._rgb = None + self._hs = None self._white = None @property @@ -44,9 +45,9 @@ def brightness(self): 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 @@ def is_on(self): """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 @@ def _turn_on_dimmer(self, **kwargs): 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 @@ def _turn_on_rgb_and_w(self, hex_template, **kwargs): 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): + 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 @@ def turn_off(self): # 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 @@ def _update_dimmer(self): 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 ecb120e30799b..8d7fb807c6dbb 100644 --- a/homeassistant/components/light/mystrom.py +++ b/homeassistant/components/light/mystrom.py @@ -11,16 +11,20 @@ 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 @@ 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 @@ def __init__(self, bulb, name): 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 @@ def brightness(self): """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 @@ def turn_on(self, **kwargs): 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 @@ def update(self): 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 0000000000000..99c07166037e4 --- /dev/null +++ b/homeassistant/components/light/nanoleaf_aurora.py @@ -0,0 +1,151 @@ +""" +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 + +REQUIREMENTS = ['nanoleaf==0.4.1'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Aurora' + +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 + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + aurora_light = nanoleaf.Aurora(host, token) + aurora_light.hass_name = name + + if aurora_light.on is None: + _LOGGER.error( + "Could not connect to Nanoleaf Aurora: %s on %s", name, host) + return + + add_devices([AuroraLight(aurora_light)], True) + + +class AuroraLight(Light): + """Representation of a Nanoleaf Aurora.""" + + def __init__(self, light): + """Initialize an Aurora light.""" + self._brightness = None + self._color_temp = None + self._effect = None + self._effects_list = None + self._light = light + self._name = light.hass_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 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 cef9f5089528b..2c44620cacaa6 100644 --- a/homeassistant/components/light/osramlightify.py +++ b/homeassistant/components/light/osramlightify.py @@ -4,37 +4,39 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.osramlightify/ """ +from datetime import timedelta import logging -import socket import random -from datetime import timedelta +import socket import voluptuous as vol from homeassistant import util -from homeassistant.const import CONF_HOST from homeassistant.components.light import ( - Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_RGB_COLOR, - ATTR_XY_COLOR, ATTR_TRANSITION, EFFECT_RANDOM, SUPPORT_BRIGHTNESS, - SUPPORT_EFFECT, SUPPORT_XY_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, - SUPPORT_TRANSITION, PLATFORM_SCHEMA) -from homeassistant.util.color import ( - color_temperature_mired_to_kelvin, color_temperature_kelvin_to_mired, - color_xy_brightness_to_RGB) + 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) +import homeassistant.util.color as color_util -REQUIREMENTS = ['lightify==1.0.6'] +REQUIREMENTS = ['lightify==1.0.6.1'] _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) -CONF_ALLOW_LIGHTIFY_GROUPS = "allow_lightify_groups" +CONF_ALLOW_LIGHTIFY_GROUPS = 'allow_lightify_groups' + 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, @@ -46,23 +48,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Osram Lightify lights.""" import lightify + host = config.get(CONF_HOST) add_groups = config.get(CONF_ALLOW_LIGHTIFY_GROUPS) - if host: - try: - bridge = lightify.Lightify(host) - except socket.error as err: - msg = "Error connecting to bridge: {} due to: {}".format( - host, str(err)) - _LOGGER.exception(msg) - return False - setup_bridge(bridge, add_devices, add_groups) - else: - _LOGGER.error("No host found in configuration") - return False - - -def setup_bridge(bridge, add_devices_callback, add_groups): + + try: + bridge = lightify.Lightify(host) + except socket.error as err: + msg = "Error connecting to bridge: {} due to: {}".format( + host, str(err)) + _LOGGER.exception(msg) + return + + setup_bridge(bridge, add_devices, add_groups) + + +def setup_bridge(bridge, add_devices, add_groups): """Set up the Lightify bridge.""" lights = {} @@ -73,17 +74,16 @@ def update_lights(): bridge.update_all_light_status() bridge.update_group_list() except TimeoutError: - _LOGGER.error('Timeout during updating of lights.') + _LOGGER.error("Timeout during updating of lights") except OSError: - _LOGGER.error('OSError during updating of lights.') + _LOGGER.error("OSError during updating of lights") new_lights = [] for (light_id, light) in bridge.lights().items(): if light_id not in lights: - osram_light = OsramLightifyLight(light_id, light, - update_lights) - + osram_light = OsramLightifyLight( + light_id, light, update_lights) lights[light_id] = osram_light new_lights.append(osram_light) else: @@ -92,28 +92,28 @@ def update_lights(): if add_groups: for (group_name, group) in bridge.groups().items(): if group_name not in lights: - osram_group = OsramLightifyGroup(group, bridge, - update_lights) + osram_group = OsramLightifyGroup( + group, bridge, update_lights) lights[group_name] = osram_group new_lights.append(osram_group) else: lights[group_name].group = group if new_lights: - add_devices_callback(new_lights) + add_devices(new_lights) update_lights() class Luminary(Light): - """ABS for Lightify Lights and Groups.""" + """Representation of Luminary Lights and Groups.""" def __init__(self, luminary, update_lights): - """Init Luminary object.""" + """Initialize a 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 +125,9 @@ def name(self): 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): @@ -141,7 +141,7 @@ def brightness(self): @property def is_on(self): - """Update Status to True if device is on.""" + """Update status to True if device is on.""" return self._state @property @@ -158,73 +158,42 @@ def turn_on(self, **kwargs): """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) + 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: effect = kwargs.get(ATTR_EFFECT) if effect == EFFECT_RANDOM: - 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._luminary.set_rgb( + random.randrange(0, 255), random.randrange(0, 255), + random.randrange(0, 255), transition) self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the device off.""" - _LOGGER.debug("turn_off 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() @@ -238,22 +207,22 @@ class OsramLightifyLight(Luminary): """Representation of an Osram Lightify Light.""" def __init__(self, light_id, light, update_lights): - """Initialize the light.""" + """Initialize the Lightify light.""" self._light_id = light_id super().__init__(light, update_lights) def update(self): - """Update status of a Light.""" + """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 else: self._temperature = color_temperature_kelvin_to_mired( - self._luminary.temp() - ) + self._luminary.temp()) self._brightness = int(self._luminary.lum() * 2.55) @@ -261,16 +230,13 @@ class OsramLightifyGroup(Luminary): """Representation of an Osram Lightify Group.""" def __init__(self, group, bridge, update_lights): - """Init light group.""" + """Initialize the Lightify light group.""" self._bridge = bridge self._light_ids = [] super().__init__(group, update_lights) def _get_state(self): - """Get state of group. - - The group is on, if any of the lights in on. - """ + """Get state of group.""" lights = self._bridge.lights() return any(lights[light_id].on() for light_id in self._light_ids) @@ -280,7 +246,8 @@ def update(self): 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 40798810c0e0c..755cf9dca6691 100644 --- a/homeassistant/components/light/piglow.py +++ b/homeassistant/components/light/piglow.py @@ -11,15 +11,16 @@ 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 @@ def __init__(self, piglow, name): 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 @@ def brightness(self): 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 @@ def turn_on(self, **kwargs): 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 63051d2ea8c14..528f4f73c53d5 100644 --- a/homeassistant/components/light/qwikswitch.py +++ b/homeassistant/components/light/qwikswitch.py @@ -4,21 +4,32 @@ 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 +DEPENDENCIES = [QWIKSWITCH] -_LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['qwikswitch'] +async def async_setup_platform(hass, _, add_devices, discovery_info=None): + """Add lights from the main Qwikswitch component.""" + if discovery_info is None: + return + qsusb = hass.data[QWIKSWITCH] + devs = [QSLight(qsid, qsusb) for qsid in discovery_info[QWIKSWITCH]] + add_devices(devs) -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the lights from the main Qwikswitch component.""" - if discovery_info is None: - _LOGGER.error("Configure Qwikswitch component failed") - return False - add_devices(qwikswitch.QSUSB['light']) - return True +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/rflink.py b/homeassistant/components/light/rflink.py index 0b56f1de0acb4..a05822ed8d173 100644 --- a/homeassistant/components/light/rflink.py +++ b/homeassistant/components/light/rflink.py @@ -48,7 +48,7 @@ vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_NOGROUP_ALIASES, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_FIRE_EVENT): cv.boolean, vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), vol.Optional(CONF_GROUP, default=True): cv.boolean, # deprecated config options @@ -123,7 +123,7 @@ def devices_from_config(domain_config, hass=None): _LOGGER.warning( "Hybrid type for %s not compatible with signal " "repetitions. Please set 'dimmable' or 'switchable' " - "type explicity in configuration", device_id) + "type explicitly in configuration", device_id) device = entity_class(device_id, hass, **device_config) devices.append(device) diff --git a/homeassistant/components/light/rfxtrx.py b/homeassistant/components/light/rfxtrx.py index 9248b0131f165..cdfe2fe567198 100644 --- a/homeassistant/components/light/rfxtrx.py +++ b/homeassistant/components/light/rfxtrx.py @@ -6,15 +6,32 @@ """ import logging +import voluptuous as vol + import homeassistant.components.rfxtrx as rfxtrx -from homeassistant.components.light import (ATTR_BRIGHTNESS, - SUPPORT_BRIGHTNESS, Light) +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, 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 DEPENDENCIES = ['rfxtrx'] _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = rfxtrx.DEFAULT_SCHEMA +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_AUTOMATIC_ADD, default=False): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS): + vol.Coerce(int), +}) SUPPORT_RFXTRX = SUPPORT_BRIGHTNESS diff --git a/homeassistant/components/light/rpi_gpio_pwm.py b/homeassistant/components/light/rpi_gpio_pwm.py index 55b64bf8a74f3..9385c4bfb804b 100644 --- a/homeassistant/components/light/rpi_gpio_pwm.py +++ b/homeassistant/components/light/rpi_gpio_pwm.py @@ -10,9 +10,10 @@ 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_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 @@ def __init__(self, led, name): self._color = DEFAULT_COLOR @property - def rgb_color(self): + def hs_color(self): """Return the color property.""" return self._color @@ -180,8 +181,8 @@ def supported_features(self): 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 6c5467f8c6d3d..6ab2592cedf91 100644 --- a/homeassistant/components/light/sensehat.py +++ b/homeassistant/components/light/sensehat.py @@ -10,15 +10,16 @@ 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 @@ def __init__(self, sensehat, name): 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 @@ def brightness(self): 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 @@ def turn_on(self, **kwargs): """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 782d449644239..3507c6d2cdacc 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -1,202 +1,194 @@ # Describes the format for available light services turn_on: - description: Turn a light on - + description: Turn a light on. fields: entity_id: description: Name(s) of entities to turn on example: 'light.kitchen' - transition: description: Duration in seconds it takes to get to next state example: 60 - rgb_color: - description: Color for the light in RGB-format + description: Color for the light in RGB-format. example: '[255, 100, 100]' - color_name: - description: A human readable 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 + description: Color for the light in XY-format. example: '[0.52, 0.43]' - color_temp: - description: Color temperature for the light in mireds + description: Color temperature for the light in mireds. example: 250 - kelvin: - description: Color temperature for the light in Kelvin + description: Color temperature for the light in Kelvin. example: 4000 - white_value: - description: Number between 0..255 indicating level of white + description: Number between 0..255 indicating level of white. example: '250' - brightness: - description: Number between 0..255 indicating brightness + description: Number between 0..255 indicating brightness. example: 120 - brightness_pct: - description: Number between 0..100 indicating percentage of full brightness + description: Number between 0..100 indicating percentage of full brightness. example: 47 - profile: - description: Name of a light profile to use + description: Name of a light profile to use. example: relax - flash: - description: If the light should flash + description: If the light should flash. values: - short - long - effect: - description: Light effect + description: Light effect. values: - colorloop - random turn_off: - description: Turn a light off - + description: Turn a light off. fields: entity_id: - description: Name(s) of entities to turn off + description: Name(s) of entities to turn off. example: 'light.kitchen' - transition: - description: Duration in seconds it takes to get to next state + description: Duration in seconds it takes to get to next state. example: 60 - flash: - description: If the light should flash + description: If the light should flash. values: - short - long toggle: - description: Toggles a light - + description: Toggles a light. fields: entity_id: - description: Name(s) of entities to toggle + description: Name(s) of entities to toggle. example: 'light.kitchen' - transition: - description: Duration in seconds it takes to get to next state + description: Duration in seconds it takes to get to next state. example: 60 hue_activate_scene: - description: Activate a hue scene stored in the hue hub - + description: Activate a hue scene stored in the hue hub. fields: group_name: - description: Name of hue group/room from the hue app + description: Name of hue group/room from the hue app. example: "Living Room" - scene_name: - description: Name of hue scene from the hue app + description: Name of hue scene from the hue app. example: "Energize" lifx_set_state: - description: Set a color/brightness and possibliy turn the light on/off - + description: Set a color/brightness and possibliy turn the light on/off. fields: entity_id: - description: Name(s) of entities to set a state on + description: Name(s) of entities to set a state on. example: 'light.garage' - '...': - description: All turn_on parameters can be used to specify a color - + description: All turn_on parameters can be used to specify a color. infrared: - description: Automatic infrared level (0..255) when light brightness is low + description: Automatic infrared level (0..255) when light brightness is low. example: 255 - zones: - description: List of zone numbers to affect (8 per LIFX Z, starts at 0) + description: List of zone numbers to affect (8 per LIFX Z, starts at 0). example: '[0,5]' - transition: - description: Duration in seconds it takes to get to the final state + description: Duration in seconds it takes to get to the final state. example: 10 - power: description: Turn the light on (True) or off (False). Leave out to keep the power as it is. example: True lifx_effect_pulse: description: Run a flash effect by changing to a color and back. - fields: entity_id: - description: Name(s) of entities to run the effect on + description: Name(s) of entities to run the effect on. example: 'light.kitchen' - mode: - description: 'Decides how colors are changed. Possible values: blink, breathe, ping, strobe, solid' + description: 'Decides how colors are changed. Possible values: blink, breathe, ping, strobe, solid.' example: strobe - brightness: - description: Number between 0..255 indicating brightness of the temporary color + description: Number between 0..255 indicating brightness of the temporary color. example: 120 - color_name: - description: A human readable color name + description: A human readable color name. example: 'red' - rgb_color: - description: The temporary color in RGB-format + description: The temporary color in RGB-format. example: '[255, 100, 100]' - period: - description: Duration of the effect in seconds (default 1.0) + description: Duration of the effect in seconds (default 1.0). example: 3 - cycles: - description: Number of times the effect should run (default 1.0) + description: Number of times the effect should run (default 1.0). example: 2 - power_on: - description: Powered off lights are temporarily turned on during the effect (default True) + description: Powered off lights are temporarily turned on during the effect (default True). example: False lifx_effect_colorloop: description: Run an effect with looping colors. - fields: entity_id: - description: Name(s) of entities to run the effect on + description: Name(s) of entities to run the effect on. example: 'light.disco1, light.disco2, light.disco3' - brightness: - description: Number between 0 and 255 indicating brightness of the effect. Leave this out to maintain the current brightness of each participating light + description: Number between 0 and 255 indicating brightness of the effect. Leave this out to maintain the current brightness of each participating light. example: 120 - period: - description: Duration (in seconds) between color changes (default 60) + description: Duration (in seconds) between color changes (default 60). example: 180 - change: - description: Hue movement per period, in degrees on a color wheel (ranges from 0 to 360, default 20) + description: Hue movement per period, in degrees on a color wheel (ranges from 0 to 360, default 20). example: 45 - spread: - description: Maximum hue difference between participating lights, in degrees on a color wheel (ranges from 0 to 360, default 30) + description: Maximum hue difference between participating lights, in degrees on a color wheel (ranges from 0 to 360, default 30). example: 0 - power_on: - description: Powered off lights are temporarily turned on during the effect (default True) + description: Powered off lights are temporarily turned on during the effect (default True). example: False lifx_effect_stop: description: Stop a running effect. - fields: entity_id: description: Name(s) of entities to stop effects on. Leave out to stop effects everywhere. example: 'light.bedroom' + +xiaomi_miio_set_scene: + description: Set a fixed scene. + fields: + entity_id: + description: Name of the light entity. + example: 'light.xiaomi_miio' + scene: + description: Number of the fixed scene, between 1 and 4. + example: 1 + +xiaomi_miio_set_delayed_turn_off: + description: Delayed turn off. + fields: + entity_id: + description: Name of the light entity. + example: 'light.xiaomi_miio' + 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 new file mode 100644 index 0000000000000..d32183f146831 --- /dev/null +++ b/homeassistant/components/light/skybell.py @@ -0,0 +1,89 @@ +""" +Light/LED support for the Skybell HD Doorbell. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.skybell/ +""" +import logging + + +from homeassistant.components.light import ( + 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'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the platform for a Skybell device.""" + skybell = hass.data.get(SKYBELL_DOMAIN) + + sensors = [] + for device in skybell.get_devices(): + sensors.append(SkybellLight(device)) + + add_devices(sensors, True) + + +def _to_skybell_level(level): + """Convert the given HASS light level (0-255) to Skybell (0-100).""" + return int((level * 100) / 255) + + +def _to_hass_level(level): + """Convert the given Skybell (0-100) light level to HASS (0-255).""" + return int((level * 255) / 100) + + +class SkybellLight(SkybellDevice, Light): + """A binary sensor implementation for Skybell devices.""" + + def __init__(self, device): + """Initialize a light for a Skybell device.""" + super().__init__(device) + self._name = self._device.name + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + def turn_on(self, **kwargs): + """Turn on the light.""" + 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]) + else: + self._device.led_intensity = _to_skybell_level(255) + + def turn_off(self, **kwargs): + """Turn off the light.""" + self._device.led_intensity = 0 + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.led_intensity > 0 + + @property + def brightness(self): + """Return the brightness of the light.""" + return _to_hass_level(self._device.led_intensity) + + @property + def hs_color(self): + """Return the color of the light.""" + return color_util.color_RGB_to_hs(*self._device.led_rgb) + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR diff --git a/homeassistant/components/light/tellstick.py b/homeassistant/components/light/tellstick.py index 98af61ffb7d8f..1bf7d632af5fc 100644 --- a/homeassistant/components/light/tellstick.py +++ b/homeassistant/components/light/tellstick.py @@ -4,15 +4,13 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.tellstick/ """ -import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) from homeassistant.components.tellstick import ( DEFAULT_SIGNAL_REPETITIONS, ATTR_DISCOVER_DEVICES, ATTR_DISCOVER_CONFIG, - DOMAIN, TellstickDevice) + DATA_TELLSTICK, TellstickDevice) -PLATFORM_SCHEMA = vol.Schema({vol.Required("platform"): DOMAIN}) SUPPORT_TELLSTICK = SUPPORT_BRIGHTNESS @@ -27,17 +25,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): signal_repetitions = discovery_info.get( ATTR_DISCOVER_CONFIG, DEFAULT_SIGNAL_REPETITIONS) - add_devices(TellstickLight(tellcore_id, hass.data['tellcore_registry'], - signal_repetitions) - for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]) + add_devices([TellstickLight(hass.data[DATA_TELLSTICK][tellcore_id], + signal_repetitions) + for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]], + True) class TellstickLight(TellstickDevice, Light): """Representation of a Tellstick light.""" - def __init__(self, tellcore_id, tellcore_registry, signal_repetitions): + def __init__(self, tellcore_device, signal_repetitions): """Initialize the Tellstick light.""" - super().__init__(tellcore_id, tellcore_registry, signal_repetitions) + super().__init__(tellcore_device, signal_repetitions) self._brightness = 255 @@ -56,10 +55,9 @@ def _parse_ha_data(self, kwargs): return kwargs.get(ATTR_BRIGHTNESS) def _parse_tellcore_data(self, tellcore_data): - """Turn the value recieved from tellcore into something useful.""" - if tellcore_data is not None: - brightness = int(tellcore_data) - return brightness + """Turn the value received from tellcore into something useful.""" + if tellcore_data: + return int(tellcore_data) # brightness return None def _update_model(self, new_state, data): diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index 07703d6c067d7..38cac649a1a7f 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -13,34 +13,34 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ENTITY_ID_FORMAT, Light, SUPPORT_BRIGHTNESS) from homeassistant.const import ( - CONF_VALUE_TEMPLATE, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, STATE_ON, - STATE_OFF, EVENT_HOMEASSISTANT_START, MATCH_ALL + CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, CONF_ENTITY_PICTURE_TEMPLATE, + CONF_ENTITY_ID, CONF_FRIENDLY_NAME, STATE_ON, STATE_OFF, + EVENT_HOMEASSISTANT_START, MATCH_ALL, CONF_LIGHTS ) from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.event import async_track_state_change -from homeassistant.helpers.restore_state import async_get_last_state from homeassistant.helpers.script import Script _LOGGER = logging.getLogger(__name__) _VALID_STATES = [STATE_ON, STATE_OFF, 'true', 'false'] -CONF_LIGHTS = 'lights' CONF_ON_ACTION = 'turn_on' CONF_OFF_ACTION = 'turn_off' CONF_LEVEL_ACTION = 'set_level' CONF_LEVEL_TEMPLATE = 'level_template' - LIGHT_SCHEMA = vol.Schema({ vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_VALUE_TEMPLATE, default=None): cv.template, - vol.Optional(CONF_LEVEL_ACTION, default=None): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_LEVEL_TEMPLATE, default=None): cv.template, - vol.Optional(CONF_FRIENDLY_NAME, default=None): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, + vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_LEVEL_TEMPLATE): cv.template, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_ENTITY_ID): cv.entity_ids }) @@ -51,16 +51,19 @@ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up Template Lights.""" + """Set up the Template Lights.""" lights = [] for device, device_config in config[CONF_LIGHTS].items(): friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) - state_template = device_config[CONF_VALUE_TEMPLATE] + state_template = device_config.get(CONF_VALUE_TEMPLATE) + icon_template = device_config.get(CONF_ICON_TEMPLATE) + entity_picture_template = device_config.get( + CONF_ENTITY_PICTURE_TEMPLATE) on_action = device_config[CONF_ON_ACTION] off_action = device_config[CONF_OFF_ACTION] level_action = device_config.get(CONF_LEVEL_ACTION) - level_template = device_config[CONF_LEVEL_TEMPLATE] + level_template = device_config.get(CONF_LEVEL_TEMPLATE) template_entity_ids = set() @@ -74,6 +77,16 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if str(temp_ids) != MATCH_ALL: template_entity_ids |= set(temp_ids) + if icon_template is not None: + temp_ids = icon_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if entity_picture_template is not None: + temp_ids = entity_picture_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + if not template_entity_ids: template_entity_ids = MATCH_ALL @@ -82,15 +95,15 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): lights.append( LightTemplate( hass, device, friendly_name, state_template, - on_action, off_action, level_action, level_template, - entity_ids) + icon_template, entity_picture_template, on_action, + off_action, level_action, level_template, entity_ids) ) if not lights: _LOGGER.error("No lights added") return False - async_add_devices(lights, True) + async_add_devices(lights) return True @@ -98,14 +111,16 @@ class LightTemplate(Light): """Representation of a templated Light, including dimmable.""" def __init__(self, hass, device_id, friendly_name, state_template, - on_action, off_action, level_action, level_template, - entity_ids): + icon_template, entity_picture_template, on_action, + off_action, level_action, level_template, entity_ids): """Initialize the light.""" 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._icon_template = icon_template + self._entity_picture_template = entity_picture_template self._on_script = Script(hass, on_action) self._off_script = Script(hass, off_action) self._level_script = None @@ -114,6 +129,8 @@ def __init__(self, hass, device_id, friendly_name, state_template, self._level_template = level_template self._state = False + self._icon = None + self._entity_picture = None self._brightness = None self._entities = entity_ids @@ -121,12 +138,21 @@ def __init__(self, hass, device_id, friendly_name, state_template, self._template.hass = self.hass if self._level_template is not None: self._level_template.hass = self.hass + if self._icon_template is not None: + self._icon_template.hass = self.hass + if self._entity_picture_template is not None: + self._entity_picture_template.hass = self.hass @property def brightness(self): """Return the brightness of the light.""" return self._brightness + @property + def name(self): + """Return the display name of this light.""" + return self._name + @property def supported_features(self): """Flag supported features.""" @@ -145,17 +171,23 @@ def should_poll(self): """Return the polling state.""" return False + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + + @property + def entity_picture(self): + """Return the entity picture to use in the frontend, if any.""" + return self._entity_picture + @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - state = yield from async_get_last_state(self.hass, self.entity_id) - if state: - self._state = state.state == STATE_ON - @callback def template_light_state_listener(entity, old_state, new_state): """Handle target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @callback def template_light_startup(event): @@ -165,7 +197,7 @@ def template_light_startup(event): async_track_state_change( self.hass, self._entities, template_light_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_light_startup) @@ -189,18 +221,18 @@ def async_turn_on(self, **kwargs): self.hass.async_add_job(self._level_script.async_run( {"brightness": kwargs[ATTR_BRIGHTNESS]})) else: - self.hass.async_add_job(self._on_script.async_run()) + yield from self._on_script.async_run() if optimistic_set: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn the light off.""" - self.hass.async_add_job(self._off_script.async_run()) + yield from self._off_script.async_run() if self._template is None: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_update(self): @@ -229,10 +261,35 @@ def async_update(self): self._state = None if 0 <= int(brightness) <= 255: - self._brightness = brightness + self._brightness = int(brightness) else: _LOGGER.error( 'Received invalid brightness : %s' + 'Expected: 0-255', brightness) self._brightness = None + + for property_name, template in ( + ('_icon', self._icon_template), + ('_entity_picture', self._entity_picture_template)): + if template is None: + continue + + try: + setattr(self, property_name, template.async_render()) + except TemplateError as ex: + friendly_property_name = property_name[1:].replace('_', ' ') + if ex.args and ex.args[0].startswith( + "UndefinedError: 'None' has no attribute"): + # Common during HA startup - so just a warning + _LOGGER.warning('Could not render %s template %s,' + ' the state is unknown.', + friendly_property_name, self._name) + return + + try: + setattr(self, property_name, + getattr(super(), property_name)) + except AttributeError: + _LOGGER.error('Could not render %s template %s: %s', + friendly_property_name, self._name, ex) diff --git a/homeassistant/components/light/tikteck.py b/homeassistant/components/light/tikteck.py index 07d4b63e99a74..2079638f7f104 100644 --- a/homeassistant/components/light/tikteck.py +++ b/homeassistant/components/light/tikteck.py @@ -10,15 +10,16 @@ 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 @@ def __init__(self, device): 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( @@ -70,7 +71,7 @@ def __init__(self, device): @property def unique_id(self): """Return the ID of this light.""" - return "{}.{}".format(self.__class__, self._address) + return self._address @property def name(self): @@ -88,9 +89,9 @@ def brightness(self): 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 @@ def turn_on(self, **kwargs): """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 14288b8848d61..4101eab215029 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -5,23 +5,34 @@ 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) + 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.2.4.2'] +REQUIREMENTS = ['pyHS100==0.3.0'] _LOGGER = logging.getLogger(__name__) -SUPPORT_TPLINK = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP) +ATTR_CURRENT_POWER_W = 'current_power_w' +ATTR_DAILY_ENERGY_KWH = 'daily_energy_kwh' +ATTR_MONTHLY_ENERGY_KWH = 'monthly_energy_kwh' + +DEFAULT_NAME = 'TP-Link Light' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -42,46 +53,36 @@ 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.""" - def __init__(self, smartbulb: 'SmartBulb', name): + def __init__(self, smartbulb: 'SmartBulb', name) -> None: """Initialize the bulb.""" self.smartbulb = smartbulb - - # Use the name set on the device if not set - if name is None: - self._name = self.smartbulb.alias - else: - self._name = name - + self._name = name self._state = None + self._available = True self._color_temp = None self._brightness = None - self._rgb = None - _LOGGER.debug("Setting up TP-Link Smart Bulb") + self._hs = None + self._supported_features = 0 + self._emeter_params = {} @property def name(self): """Return the name of the Smart Bulb, if any.""" return self._name + @property + def available(self) -> bool: + """Return if bulb is available.""" + return self._available + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._emeter_params + def turn_on(self, **kwargs): """Turn the light on.""" self.smartbulb.state = self.smartbulb.BULB_STATE_ON @@ -89,16 +90,17 @@ def turn_on(self, **kwargs): 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) - - def turn_off(self): + + 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.""" self.smartbulb.state = self.smartbulb.BULB_STATE_OFF @@ -113,36 +115,75 @@ def brightness(self): 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): - """True if device is on.""" + """Return True if device is on.""" return self._state def update(self): """Update the TP-Link Bulb's state.""" - from pyHS100 import SmartPlugException + from pyHS100 import SmartDeviceException try: + self._available = True + + if self._supported_features == 0: + self.get_features() + self._state = ( self.smartbulb.state == self.smartbulb.BULB_STATE_ON) - self._brightness = brightness_from_percentage( - self.smartbulb.brightness) - if self.smartbulb.is_color: + + # Pull the name from the device if a name was not specified + if self._name == DEFAULT_NAME: + self._name = self.smartbulb.alias + + if self._supported_features & SUPPORT_BRIGHTNESS: + self._brightness = brightness_from_percentage( + self.smartbulb.brightness) + + if self._supported_features & SUPPORT_COLOR_TEMP: if (self.smartbulb.color_temp is not None and self.smartbulb.color_temp != 0): self._color_temp = kelvin_to_mired( self.smartbulb.color_temp) - self._rgb = hsv_to_rgb(self.smartbulb.hsv) - except (SmartPlugException, OSError) as ex: - _LOGGER.warning('Could not read state for %s: %s', self.name, ex) + + 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( + self.smartbulb.current_consumption()) + daily_statistics = self.smartbulb.get_emeter_daily() + monthly_statistics = self.smartbulb.get_emeter_monthly() + try: + self._emeter_params[ATTR_DAILY_ENERGY_KWH] \ + = "{:.3f}".format( + daily_statistics[int(time.strftime("%d"))]) + self._emeter_params[ATTR_MONTHLY_ENERGY_KWH] \ + = "{:.3f}".format( + monthly_statistics[int(time.strftime("%m"))]) + except KeyError: + # device returned no daily/monthly history + pass + + except (SmartDeviceException, OSError) as ex: + _LOGGER.warning("Could not read state for %s: %s", self._name, ex) + self._available = False @property def supported_features(self): """Flag supported features.""" - supported_features = SUPPORT_TPLINK + return self._supported_features + + def get_features(self): + """Determine all supported features in one go.""" + if self.smartbulb.is_dimmable: + self._supported_features += SUPPORT_BRIGHTNESS + if self.smartbulb.is_variable_color_temp: + self._supported_features += SUPPORT_COLOR_TEMP if self.smartbulb.is_color: - supported_features += SUPPORT_RGB_COLOR - return supported_features + self._supported_features += SUPPORT_COLOR diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index fa21af996cb3c..ab53c3669cb72 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -6,24 +6,29 @@ """ import logging +from homeassistant.core import callback from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, - SUPPORT_COLOR_TEMP, SUPPORT_RGB_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 + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION, SUPPORT_COLOR_TEMP, + 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 +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' -ALLOWED_TEMPERATURES = {IKEA} +TRADFRI_LIGHT_MANAGER = 'Tradfri Light Manager' +SUPPORTED_FEATURES = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION) -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 IKEA Tradfri Light platform.""" if discovery_info is None: return @@ -31,29 +36,50 @@ def setup_platform(hass, config, add_devices, discovery_info=None): gateway_id = discovery_info['gateway'] api = hass.data[KEY_API][gateway_id] gateway = hass.data[KEY_GATEWAY][gateway_id] - devices = api(gateway.get_devices()) - lights = [dev for dev in devices if api(dev).has_light_control] - add_devices(Tradfri(light, api) for light in lights) + + devices_command = gateway.get_devices() + 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, gateway_id) for light in lights) allow_tradfri_groups = hass.data[KEY_TRADFRI_GROUPS][gateway_id] if allow_tradfri_groups: - groups = api(gateway.get_groups()) - add_devices(TradfriGroup(group, api) for group in groups) + groups_command = gateway.get_groups() + groups_commands = await api(groups_command) + groups = await api(groups_commands) + if 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._group = api(light) self._api = api - self._name = self._group.name + self._unique_id = "group-{}-{}".format(gateway_id, group.id) + self._group = group + self._name = group.name + + self._refresh(group) + + async def async_added_to_hass(self): + """Start thread when added to hass.""" + self._async_start_observe() + + @property + def unique_id(self): + """Return unique ID for this group.""" + return self._unique_id @property def supported_features(self): """Flag supported features.""" - return SUPPORT_BRIGHTNESS + return SUPPORTED_FEATURES @property def name(self): @@ -70,61 +96,104 @@ def brightness(self): """Return the brightness of the group lights.""" return self._group.dimmer - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Instruct the group lights to turn off.""" - self._api(self._group.set_state(0)) + await self._api(self._group.set_state(0)) - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Instruct the group lights to turn on, or dim.""" - if ATTR_BRIGHTNESS in kwargs: - self._api(self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS])) - else: - self._api(self._group.set_state(1)) + keys = {} + if ATTR_TRANSITION in kwargs: + keys['transition_time'] = int(kwargs[ATTR_TRANSITION]) * 10 - def update(self): - """Fetch new state data for this group.""" - from pytradfri import RequestTimeout - try: - self._api(self._group.update()) - except RequestTimeout: - _LOGGER.warning("Tradfri update request timed out") + if ATTR_BRIGHTNESS in kwargs: + if kwargs[ATTR_BRIGHTNESS] == 255: + kwargs[ATTR_BRIGHTNESS] = 254 + await self._api( + self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS], **keys)) + else: + await self._api(self._group.set_state(1)) -class Tradfri(Light): - """The platform class required by Home Asisstant.""" + @callback + def _async_start_observe(self, exc=None): + """Start observation of light.""" + # pylint: disable=import-error + from pytradfri.error import PytradfriError + if exc: + _LOGGER.warning("Observation failed for %s", self._name, + exc_info=exc) - def __init__(self, light, api): + try: + cmd = self._group.observe(callback=self._observe_update, + err_callback=self._async_start_observe, + duration=0) + self.hass.async_add_job(self._api(cmd)) + except PytradfriError as err: + _LOGGER.warning("Observation failed, trying again", exc_info=err) + self._async_start_observe() + + def _refresh(self, group): + """Refresh the light data.""" + self._group = group + self._name = group.name + + @callback + def _observe_update(self, tradfri_device): + """Receive new state data for this 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, gateway_id): """Initialize a Light.""" - self._light = api(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._hs_color = None + self._features = SUPPORTED_FEATURES + self._available = True - # Caching of LightControl and light object - self._light_control = self._light.light_control - self._light_data = self._light_control.lights[0] - self._name = self._light.name - self._rgb_color = None - self._features = SUPPORT_BRIGHTNESS - - if self._light_data.hex_color is not None: - if self._light.device_info.manufacturer == IKEA: - self._features |= SUPPORT_COLOR_TEMP - else: - self._features |= SUPPORT_RGB_COLOR + self._refresh(light) - self._ok_temps = \ - self._light.device_info.manufacturer in ALLOWED_TEMPERATURES + @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.""" - from pytradfri.color import MAX_KELVIN_WS - return color_util.color_temperature_kelvin_to_mired(MAX_KELVIN_WS) + return self._light_control.min_mireds @property def max_mireds(self): """Return the warmest color_temp that this light supports.""" - from pytradfri.color import MIN_KELVIN_WS - return color_util.color_temperature_kelvin_to_mired(MIN_KELVIN_WS) + return self._light_control.max_mireds + + async def async_added_to_hass(self): + """Start thread when added to hass.""" + self._async_start_observe() + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def should_poll(self): + """No polling needed for tradfri light.""" + return False @property def supported_features(self): @@ -148,56 +217,120 @@ def brightness(self): @property def color_temp(self): - """Return the CT color value in mireds.""" - if (self._light_data.kelvin_color is None or - self.supported_features & SUPPORT_COLOR_TEMP == 0 or - not self._ok_temps): - return None - return color_util.color_temperature_kelvin_to_mired( - self._light_data.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 turn_off(self, **kwargs): + 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 + + async def async_turn_off(self, **kwargs): """Instruct the light to turn off.""" - self._api(self._light_control.set_state(False)) - - def 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_BRIGHTNESS in kwargs: - self._api(self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS])) + await self._api(self._light_control.set_state(False)) + + async def async_turn_on(self, **kwargs): + """Instruct the light to turn on.""" + params = {} + transition_time = None + if ATTR_TRANSITION in kwargs: + transition_time = int(kwargs[ATTR_TRANSITION]) * 10 + + brightness = kwargs.get(ATTR_BRIGHTNESS) + + 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: - self._api(self._light_control.set_state(True)) - - if ATTR_RGB_COLOR in kwargs and self._light_data.hex_color is not None: - self._api(self._light.light_control.set_hex_color( - color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]))) + 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 + if exc: + _LOGGER.warning("Observation failed for %s", self._name, + exc_info=exc) - elif ATTR_COLOR_TEMP in kwargs and \ - self._light_data.hex_color is not None and self._ok_temps: - kelvin = color_util.color_temperature_mired_to_kelvin( - kwargs[ATTR_COLOR_TEMP]) - self._api(self._light_control.set_kelvin_color(kelvin)) - - def update(self): - """Fetch new state data for this light.""" - from pytradfri import RequestTimeout try: - self._api(self._light.update()) - except RequestTimeout as exception: - _LOGGER.warning("Tradfri update request timed out: %s", exception) - - # Handle Hue lights paired with the gateway - # hex_color is 0 when bulb is unreachable - if self._light_data.hex_color not in (None, '0'): - self._rgb_color = color_util.rgb_hex_to_rgb_list( - self._light_data.hex_color) + cmd = self._light.observe(callback=self._observe_update, + err_callback=self._async_start_observe, + duration=0) + self.hass.async_add_job(self._api(cmd)) + except PytradfriError as err: + _LOGGER.warning("Observation failed, trying again", exc_info=err) + self._async_start_observe() + + def _refresh(self, light): + """Refresh the light data.""" + self._light = light + + # Caching of LightControl and light object + self._available = light.reachable + self._light_control = light.light_control + self._light_data = light.light_control.lights[0] + self._name = light.name + self._features = SUPPORTED_FEATURES + + 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.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/vera.py b/homeassistant/components/light/vera.py index b3be93d82e289..6b12e69341d2c 100644 --- a/homeassistant/components/light/vera.py +++ b/homeassistant/components/light/vera.py @@ -7,10 +7,11 @@ 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__) @@ -21,7 +22,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera lights.""" add_devices( - VeraLight(device, VERA_CONTROLLER) for device in VERA_DEVICES['light']) + VeraLight(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['light']) class VeraLight(VeraDevice, Light): @@ -41,7 +43,7 @@ def brightness(self): return self._brightness @property - def rgb_color(self): + def hs_color(self): """Return the color of the light.""" return self._color @@ -49,13 +51,14 @@ def rgb_color(self): 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: @@ -82,4 +85,5 @@ def update(self): # 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 620271a107117..fcf3d2f7a7d5d 100644 --- a/homeassistant/components/light/wemo.py +++ b/homeassistant/components/light/wemo.py @@ -4,15 +4,15 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/light.wemo/ """ +import asyncio 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) + 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'] @@ -21,12 +21,12 @@ _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): - """Set up the WeMo bridges and register connected lights.""" + """Set up discovered WeMo switches.""" import pywemo.discovery as discovery if discovery_info is not None: @@ -34,7 +34,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): mac = discovery_info['mac_address'] device = discovery.device_from_description(location, mac) - if device: + if device.model_name == 'Dimmer': + add_devices([WemoDimmer(device)]) + else: setup_bridge(device, add_devices) @@ -72,8 +74,7 @@ def __init__(self, device, update_lights): @property def unique_id(self): """Return the ID of this light.""" - deviceid = self.device.uniqueID - return '{}.{}'.format(self.__class__, deviceid) + return self.device.uniqueID @property def name(self): @@ -86,9 +87,10 @@ def brightness(self): 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): @@ -109,17 +111,11 @@ def turn_on(self, **kwargs): """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] @@ -140,3 +136,88 @@ def turn_off(self, **kwargs): def update(self): """Synchronize state with bridge.""" self.update_lights(no_throttle=True) + + +class WemoDimmer(Light): + """Representation of a WeMo dimmer.""" + + def __init__(self, device): + """Initialize the WeMo dimmer.""" + self.wemo = device + self._brightness = None + self._state = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Register update callback.""" + 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( + wemo.SUBSCRIPTION_REGISTRY.register, self.wemo) + # The on method just appends to a defaultdict list. + wemo.SUBSCRIPTION_REGISTRY.on(self.wemo, None, self._update_callback) + + def _update_callback(self, _device, _type, _params): + """Update the state by the Wemo device.""" + _LOGGER.debug("Subscription update for %s", _device) + updated = self.wemo.subscription_update(_type, _params) + self._update(force_update=(not updated)) + self.schedule_update_ha_state() + + @property + def unique_id(self): + """Return the ID of this WeMo dimmer.""" + return self.wemo.serialnumber + + @property + def name(self): + """Return the name of the dimmer if any.""" + return self.wemo.name + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + @property + def should_poll(self): + """No polling needed with subscriptions.""" + return False + + @property + def brightness(self): + """Return the brightness of this light between 1 and 100.""" + return self._brightness + + @property + def is_on(self): + """Return true if dimmer is on. Standby is on.""" + return self._state + + def _update(self, force_update=True): + """Update the device state.""" + try: + self._state = self.wemo.get_state(force_update) + wemobrightness = int(self.wemo.get_brightness(force_update)) + self._brightness = int((wemobrightness * 255) / 100) + except AttributeError as err: + _LOGGER.warning("Could not update status for %s (%s)", + self.name, err) + + def turn_on(self, **kwargs): + """Turn the dimmer on.""" + self.wemo.on() + + # Wemo dimmer switches use a range of [0, 100] to control + # brightness. Level 255 might mean to set it to previous value + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + brightness = int((brightness / 255) * 100) + else: + brightness = 255 + self.wemo.set_brightness(brightness) + + def turn_off(self, **kwargs): + """Turn the dimmer off.""" + self.wemo.off() diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 445fe8ceb25f2..a2cc4fd7aeb5b 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -5,20 +5,17 @@ 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) -from homeassistant.components.wink import WinkDevice, DOMAIN + 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 \ color_temperature_mired_to_kelvin as mired_to_kelvin 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.""" @@ -39,7 +36,7 @@ class WinkLight(WinkDevice, Light): @asyncio.coroutine def async_added_to_hass(self): - """Callback when entity is added to hass.""" + """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['light'].append(self) @property @@ -55,28 +52,18 @@ def brightness(self): 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 @@ def color_temp(self): @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) @@ -118,6 +109,6 @@ def turn_on(self, **kwargs): self.wink.set_state(True, **state_kwargs) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" self.wink.set_state(False) diff --git a/homeassistant/components/light/xiaomi.py b/homeassistant/components/light/xiaomi_aqara.py old mode 100755 new mode 100644 similarity index 71% rename from homeassistant/components/light/xiaomi.py rename to homeassistant/components/light/xiaomi_aqara.py index d8a70b726f47b..37ae60e3494db --- a/homeassistant/components/light/xiaomi.py +++ b/homeassistant/components/light/xiaomi_aqara.py @@ -2,10 +2,12 @@ import logging import struct import binascii -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) -from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR, +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) +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__) @@ -16,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) @@ -28,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) @@ -38,7 +40,7 @@ def is_on(self): """Return true if it is on.""" return self._state - def parse_data(self, data): + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" value = data.get(self._data_key) if value is None: @@ -50,20 +52,20 @@ def parse_data(self, data): return True rgbhexstr = "%x" % value - if len(rgbhexstr) == 7: - rgbhexstr = '0' + rgbhexstr - elif len(rgbhexstr) != 8: - _LOGGER.error('Light RGB data error.' - ' Must be 8 characters. Received: %s', rgbhexstr) + if len(rgbhexstr) > 8: + _LOGGER.error("Light RGB data error." + " Can't be more than 8 characters. Received: %s", + rgbhexstr) return False + rgbhexstr = rgbhexstr.zfill(8) rgbhex = bytes.fromhex(rgbhexstr) rgba = struct.unpack('BBBB', rgbhex) brightness = rgba[0] 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 @@ -73,24 +75,25 @@ def brightness(self): 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 new file mode 100644 index 0000000000000..24eab7ebd4ad1 --- /dev/null +++ b/homeassistant/components/light/xiaomi_miio.py @@ -0,0 +1,722 @@ +""" +Support for Xiaomi Philips Lights (LED Ball & Ceiling Lamp, Eyecare Lamp 2). + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/light.xiaomi_miio/ +""" +import asyncio +from functools import partial +import logging +from math import ceil +from datetime import timedelta +import datetime + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.light import ( + PLATFORM_SCHEMA, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, + ATTR_COLOR_TEMP, SUPPORT_COLOR_TEMP, Light, ATTR_ENTITY_ID, DOMAIN, ) + +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.util import dt + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Xiaomi Philips Light' +DATA_KEY = 'light.xiaomi_miio' + +CONF_MODEL = 'model' + +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( + ['philips.light.sread1', + 'philips.light.ceiling', + 'philips.light.zyceiling', + 'philips.light.bulb', + 'philips.light.candle', + 'philips.light.candle2']), +}) + +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_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, +}) + +SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_SCENE): + vol.All(vol.Coerce(int), vol.Clamp(min=1, max=4)) +}) + +SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_TIME_PERIOD): + vol.All(cv.time_period, cv.positive_timedelta) +}) + +SERVICE_TO_METHOD = { + SERVICE_SET_DELAYED_TURN_OFF: { + 'method': 'async_set_delayed_turn_off', + 'schema': SERVICE_SCHEMA_SET_DELAYED_TURN_OFF}, + 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 +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: + 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]) + + 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, + device_info.hardware_version) + except DeviceException: + raise PlatformNotReady + + if model == 'philips.light.sread1': + from miio import PhilipsEyecare + light = PhilipsEyecare(host, token) + 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, 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 = 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/syssi/philipslight/issues ' + 'and provide the following data: %s', model) + return False + + async_add_devices(devices, update_before_add=True) + + 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() + 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() + + update_tasks = [] + for target_device in target_devices: + 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: + await asyncio.wait(update_tasks, loop=hass.loop) + + for xiaomi_miio_service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[xiaomi_miio_service].get( + 'schema', XIAOMI_MIIO_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, xiaomi_miio_service, async_service_handler, schema=schema) + + +class XiaomiPhilipsAbstractLight(Light): + """Representation of a Abstract Xiaomi Philips Light.""" + + 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._available = False + self._state = None + self._state_attrs = { + ATTR_MODEL: self._model, + } + + @property + def should_poll(self): + """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.""" + return self._name + + @property + def available(self): + """Return true when state is known.""" + return self._available + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state_attrs + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_BRIGHTNESS + + async def _try_command(self, mask_error, func, *args, **kwargs): + """Call a light command handling error messages.""" + from miio import DeviceException + try: + result = await self.hass.async_add_job( + partial(func, *args, **kwargs)) + + _LOGGER.debug("Response received from light: %s", result) + + return result == SUCCESS + except DeviceException as exc: + _LOGGER.error(mask_error, exc) + self._available = False + return False + + 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: %s %s%%", + brightness, percent_brightness) + + result = await self._try_command( + "Setting brightness failed: %s", + self._light.set_brightness, percent_brightness) + + if result: + self._brightness = brightness + else: + await self._try_command( + "Turning the light on failed.", self._light.on) + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + await self._try_command( + "Turning the light off failed.", self._light.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.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) + + 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, + }) + + except DeviceException as ex: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) + + async def async_set_scene(self, scene: int = 1): + """Set the fixed scene.""" + await self._try_command( + "Setting a fixed scene failed.", + self._light.set_scene, scene) + + 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 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(microsecond=0) + \ + timedelta(seconds=countdown) + + if previous is None: + return new + + 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 + + return new + + return None + + +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): + """Return the color temperature.""" + return self._color_temp + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 175 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 333 + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_COLOR_TEMP in kwargs: + color_temp = kwargs[ATTR_COLOR_TEMP] + percent_color_temp = self.translate( + color_temp, self.max_mireds, + self.min_mireds, CCT_MIN, CCT_MAX) + + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + percent_brightness = ceil(100 * brightness / 255.0) + + if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs: + _LOGGER.debug( + "Setting brightness and color temperature: " + "%s %s%%, %s mireds, %s%% cct", + brightness, percent_brightness, + color_temp, percent_color_temp) + + result = await self._try_command( + "Setting brightness and color temperature failed: " + "%s bri, %s cct", + self._light.set_brightness_and_color_temperature, + percent_brightness, percent_color_temp) + + if result: + self._color_temp = color_temp + self._brightness = brightness + + elif ATTR_COLOR_TEMP in kwargs: + _LOGGER.debug( + "Setting color temperature: " + "%s mireds, %s%% cct", + color_temp, percent_color_temp) + + result = await self._try_command( + "Setting color temperature failed: %s cct", + self._light.set_color_temperature, percent_color_temp) + + if result: + self._color_temp = color_temp + + elif ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + percent_brightness = ceil(100 * brightness / 255.0) + + _LOGGER.debug( + "Setting brightness: %s %s%%", + brightness, percent_brightness) + + result = await self._try_command( + "Setting brightness failed: %s", + self._light.set_brightness, percent_brightness) + + if result: + self._brightness = brightness + + else: + await self._try_command( + "Turning the light on failed.", self._light.on) + + 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) + 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, + }) + + except DeviceException as ex: + 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(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.""" + return 175 + + @property + def max_mireds(self): + """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) + + 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.""" + + 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/xiaomi_philipslight.py b/homeassistant/components/light/xiaomi_philipslight.py deleted file mode 100644 index 8df25153a733e..0000000000000 --- a/homeassistant/components/light/xiaomi_philipslight.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Support for Xiaomi Philips Lights (LED Ball & Ceil). - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/light.xiaomi_philipslight/ -""" -import asyncio -from functools import partial -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.light import ( - PLATFORM_SCHEMA, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, - ATTR_COLOR_TEMP, SUPPORT_COLOR_TEMP, Light, ) - -from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ) -from homeassistant.exceptions import PlatformNotReady - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = 'Xiaomi Philips Light' -PLATFORM = 'xiaomi_philipslight' -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-mirobo==0.1.3'] - -# The light does not accept cct values < 1 -CCT_MIN = 1 -CCT_MAX = 100 - -SUCCESS = ['ok'] -ATTR_MODEL = 'model' - - -# pylint: disable=unused-argument -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the light from config.""" - from mirobo import Ceil, DeviceException - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} - - host = config.get(CONF_HOST) - name = config.get(CONF_NAME) - token = config.get(CONF_TOKEN) - - _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) - - try: - light = Ceil(host, token) - device_info = light.info() - _LOGGER.info("%s %s %s initialized", - device_info.raw['model'], - device_info.raw['fw_ver'], - device_info.raw['hw_ver']) - - philips_light = XiaomiPhilipsLight(name, light, device_info) - hass.data[PLATFORM][host] = philips_light - except DeviceException: - raise PlatformNotReady - - async_add_devices([philips_light], update_before_add=True) - - -class XiaomiPhilipsLight(Light): - """Representation of a Xiaomi Philips Light.""" - - def __init__(self, name, light, device_info): - """Initialize the light device.""" - self._name = name - self._device_info = device_info - - self._brightness = None - self._color_temp = None - - self._light = light - self._state = None - self._state_attrs = { - ATTR_MODEL: self._device_info.raw['model'], - } - - @property - def should_poll(self): - """Poll the light.""" - return True - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def available(self): - """Return true when state is known.""" - return self._state is not None - - @property - def device_state_attributes(self): - """Return the state attributes of the device.""" - return self._state_attrs - - @property - def is_on(self): - """Return true if light is on.""" - return self._state - - @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - return self._brightness - - @property - def color_temp(self): - """Return the color temperature.""" - return self._color_temp - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return 175 - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return 333 - - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP - - @asyncio.coroutine - def _try_command(self, mask_error, func, *args, **kwargs): - """Call a light command handling error messages.""" - from mirobo import DeviceException - try: - result = yield from self.hass.async_add_job( - partial(func, *args, **kwargs)) - - _LOGGER.debug("Response received from light: %s", result) - - return result == SUCCESS - except DeviceException as exc: - _LOGGER.error(mask_error, exc) - return False - - @asyncio.coroutine - def async_turn_on(self, **kwargs): - """Turn the light on.""" - if ATTR_BRIGHTNESS in kwargs: - brightness = kwargs[ATTR_BRIGHTNESS] - percent_brightness = int(100 * brightness / 255) - - _LOGGER.debug( - "Setting brightness: %s %s%%", - self.brightness, percent_brightness) - - result = yield from self._try_command( - "Setting brightness failed: %s", - self._light.set_bright, percent_brightness) - - if result: - self._brightness = brightness - - if ATTR_COLOR_TEMP in kwargs: - color_temp = kwargs[ATTR_COLOR_TEMP] - percent_color_temp = self.translate( - color_temp, self.max_mireds, - self.min_mireds, CCT_MIN, CCT_MAX) - - _LOGGER.debug( - "Setting color temperature: " - "%s mireds, %s%% cct", - color_temp, percent_color_temp) - - result = yield from self._try_command( - "Setting color temperature failed: %s cct", - self._light.set_cct, percent_color_temp) - - if result: - self._color_temp = color_temp - - result = yield from self._try_command( - "Turning the light on failed.", self._light.on) - - if result: - self._state = True - - @asyncio.coroutine - def async_turn_off(self, **kwargs): - """Turn the light off.""" - result = yield from self._try_command( - "Turning the light off failed.", self._light.off) - - if result: - self._state = True - - @asyncio.coroutine - def async_update(self): - """Fetch state from the device.""" - from mirobo import DeviceException - try: - state = yield from self.hass.async_add_job(self._light.status) - _LOGGER.debug("Got new state: %s", state.data) - - self._state = state.is_on - self._brightness = int(255 * 0.01 * state.bright) - self._color_temp = self.translate(state.cct, CCT_MIN, CCT_MAX, - self.max_mireds, - self.min_mireds) - - except DeviceException as ex: - _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)) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 1f7ee2ba5f937..202c6ac594d80 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -5,38 +5,44 @@ 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_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, FLASH_SHORT, FLASH_LONG, ATTR_EFFECT, - SUPPORT_BRIGHTNESS, SUPPORT_RGB_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.3.2'] +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, @@ -50,10 +56,14 @@ SUPPORT_FLASH) SUPPORT_YEELIGHT_RGB = (SUPPORT_YEELIGHT | - SUPPORT_RGB_COLOR | + SUPPORT_COLOR | SUPPORT_EFFECT | SUPPORT_COLOR_TEMP) +YEELIGHT_MIN_KELVIN = YEELIGHT_MAX_KELVIN = 2700 +YEELIGHT_RGB_MIN_KELVIN = 1700 +YEELIGHT_RGB_MAX_KELVIN = 6500 + EFFECT_DISCO = "Disco" EFFECT_TEMP = "Slow Temp" EFFECT_STROBE = "Strobe epilepsy!" @@ -89,13 +99,12 @@ 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): @@ -113,25 +122,59 @@ def _wrap(self, *args, **kwargs): 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.""" @@ -149,7 +192,7 @@ def __init__(self, device, config): self._brightness = None self._color_temp = None self._is_on = None - self._rgb = None + self._hs = None @property def available(self) -> bool: @@ -166,11 +209,6 @@ def effect_list(self): """Return the list of supported effects.""" return YEELIGHT_EFFECT_LIST - @property - def unique_id(self) -> str: - """Return the ID of this light.""" - return "{}.{}".format(self.__class__, self._ipaddr) - @property def color_temp(self) -> int: """Return the color temperature.""" @@ -191,32 +229,46 @@ def brightness(self) -> int: """Return the brightness of this light between 1..255.""" return self._brightness - def _get_rgb_from_properties(self): + @property + def min_mireds(self): + """Return minimum supported color temperature.""" + if self.supported_features & SUPPORT_COLOR_TEMP: + return kelvin_to_mired(YEELIGHT_RGB_MAX_KELVIN) + return kelvin_to_mired(YEELIGHT_MAX_KELVIN) + + @property + def max_mireds(self): + """Return maximum supported color temperature.""" + if self.supported_features & SUPPORT_COLOR_TEMP: + return kelvin_to_mired(YEELIGHT_RGB_MIN_KELVIN) + return kelvin_to_mired(YEELIGHT_MIN_KELVIN) + + 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 - return color_temperature_to_rgb(self.color_temp) + temp_in_k = mired_to_kelvin(self._color_temp) + 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 + return self._hs @property def _properties(self) -> dict: @@ -264,7 +316,7 @@ def update(self) -> None: if temp_in_k: self._color_temp = kelvin_to_mired(int(temp_in_k)) - self._rgb = self._get_rgb_from_properties() + self._hs = self._get_hs_from_properties() self._available = True except yeelight.BulbException as ex: @@ -283,7 +335,7 @@ def set_brightness(self, brightness, duration) -> None: @_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) @@ -319,7 +371,7 @@ def set_flash(self, flash) -> None: 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( @@ -389,7 +441,8 @@ def turn_on(self, **kwargs) -> None: 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) @@ -434,7 +487,18 @@ def turn_on(self, **kwargs) -> None: def turn_off(self, **kwargs) -> None: """Turn off.""" import yeelight + duration = int(self.config[CONF_TRANSITION]) # in ms + if ATTR_TRANSITION in kwargs: # passed kwarg overrides config + duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s try: - self._bulb.turn_off() + 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 5f48e3a0a71a5..96cce67b1bb31 100644 --- a/homeassistant/components/light/yeelightsunflower.py +++ b/homeassistant/components/light/yeelightsunflower.py @@ -10,15 +10,16 @@ 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 @@ def __init__(self, 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 @@ def brightness(self): 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 @@ def turn_on(self, **kwargs): 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 @@ def update(self): 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 b453218c7c9bf..3c77f2d8449ca 100644 --- a/homeassistant/components/light/zengge.py +++ b/homeassistant/components/light/zengge.py @@ -10,15 +10,16 @@ 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 @@ def __init__(self, device): 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 @@ -67,7 +69,7 @@ def __init__(self, device): @property def unique_id(self): """Return the ID of this light.""" - return "{}.{}".format(self.__class__, self._address) + return self._address @property def name(self): @@ -80,9 +82,14 @@ def is_on(self): 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 @@ def turn_on(self, **kwargs): 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 hs_color is not None: + self._white = 0 + self._hs_color = hs_color - if rgb is not None: + if brightness is not None: self._white = 0 - self._rgb = rgb + 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 @@ def turn_off(self, **kwargs): 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 e7ba394a977d1..b44bf820b236e 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -4,12 +4,9 @@ For more details on this platform, please refer to the documentation at https://home-assistant.io/components/light.zha/ """ -import asyncio 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__) @@ -17,20 +14,33 @@ DEFAULT_DURATION = 0.5 +CAPABILITIES_COLOR_XY = 0x08 +CAPABILITIES_COLOR_TEMP = 0x10 + +UNSUPPORTED_ATTRIBUTE = 0x86 + -@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 Zigbee Home Automation lights.""" discovery_info = zha.get_discovery_info(hass, discovery_info) if discovery_info is None: return endpoint = discovery_info['endpoint'] - try: - discovery_info['color_capabilities'] \ - = yield from endpoint.light_color['color_capabilities'] - except (AttributeError, KeyError): - pass + if hasattr(endpoint, 'light_color'): + caps = await zha.safe_read( + endpoint.light_color, ['color_capabilities']) + discovery_info['color_capabilities'] = caps.get('color_capabilities') + if discovery_info['color_capabilities'] is None: + # ZCL Version 4 devices don't support the color_capabilities + # attribute. In this version XY support is mandatory, but we need + # to probe to determine if the device supports color temperature. + discovery_info['color_capabilities'] = CAPABILITIES_COLOR_XY + result = await zha.safe_read( + endpoint.light_color, ['color_temperature']) + if result.get('color_temperature') is not UNSUPPORTED_ATTRIBUTE: + discovery_info['color_capabilities'] |= CAPABILITIES_COLOR_TEMP async_add_devices([Light(**discovery_info)], update_before_add=True) @@ -45,53 +55,46 @@ def __init__(self, **kwargs): super().__init__(**kwargs) self._supported_features = 0 self._color_temp = None - self._xy_color = None + self._hs_color = None self._brightness = None - import bellows.zigbee.zcl.clusters as zcl_clusters + import zigpy.zcl.clusters as zcl_clusters if zcl_clusters.general.LevelControl.cluster_id in self._in_clusters: self._supported_features |= light.SUPPORT_BRIGHTNESS self._supported_features |= light.SUPPORT_TRANSITION self._brightness = 0 if zcl_clusters.lighting.Color.cluster_id in self._in_clusters: - color_capabilities = kwargs.get('color_capabilities', 0x10) - if color_capabilities & 0x10: + color_capabilities = kwargs['color_capabilities'] + if color_capabilities & CAPABILITIES_COLOR_TEMP: self._supported_features |= light.SUPPORT_COLOR_TEMP - if color_capabilities & 0x08: - self._supported_features |= light.SUPPORT_XY_COLOR - self._supported_features |= light.SUPPORT_RGB_COLOR - self._xy_color = (1.0, 1.0) + if color_capabilities & CAPABILITIES_COLOR_XY: + 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) - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the entity on.""" duration = kwargs.get(light.ATTR_TRANSITION, DEFAULT_DURATION) duration = duration * 10 # tenths of s if light.ATTR_COLOR_TEMP in kwargs: temperature = kwargs[light.ATTR_COLOR_TEMP] - yield from self._endpoint.light_color.move_to_color_temp( + await self._endpoint.light_color.move_to_color_temp( 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: - yield from self._endpoint.light_color.move_to_color( - int(self._xy_color[0] * 65535), - int(self._xy_color[1] * 65535), + 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(xy_color[0] * 65535), + int(xy_color[1] * 65535), duration, ) @@ -100,24 +103,34 @@ def async_turn_on(self, **kwargs): light.ATTR_BRIGHTNESS, self._brightness or 255) self._brightness = brightness # Move to level with on/off: - yield from self._endpoint.level.move_to_level_with_on_off( + await self._endpoint.level.move_to_level_with_on_off( brightness, duration ) self._state = 1 - self.hass.async_add_job(self.async_update_ha_state()) + 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 - yield from self._endpoint.on_off.on() self._state = 1 - self.hass.async_add_job(self.async_update_ha_state()) + 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.""" - yield from 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.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def brightness(self): @@ -125,9 +138,9 @@ def brightness(self): 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): @@ -139,46 +152,28 @@ def supported_features(self): """Flag supported features.""" return self._supported_features - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Retrieve latest state.""" - _LOGGER.debug("%s async_update", self.entity_id) - - @asyncio.coroutine - def safe_read(cluster, attributes): - """Swallow all exceptions from network read. - - If we throw during initialization, setup fails. Rather have an - entity that exists, but is in a maybe wrong state, than no entity. - """ - try: - result, _ = yield from cluster.read_attributes( - attributes, - allow_cache=False, - ) - return result - except Exception: # pylint: disable=broad-except - return {} - - result = yield from safe_read(self._endpoint.on_off, ['on_off']) + result = await zha.safe_read(self._endpoint.on_off, ['on_off']) self._state = result.get('on_off', self._state) if self._supported_features & light.SUPPORT_BRIGHTNESS: - result = yield from safe_read(self._endpoint.level, - ['current_level']) + result = await zha.safe_read(self._endpoint.level, + ['current_level']) self._brightness = result.get('current_level', self._brightness) if self._supported_features & light.SUPPORT_COLOR_TEMP: - result = yield from safe_read(self._endpoint.light_color, - ['color_temperature']) + result = await zha.safe_read(self._endpoint.light_color, + ['color_temperature']) self._color_temp = result.get('color_temperature', self._color_temp) - if self._supported_features & light.SUPPORT_XY_COLOR: - result = yield from safe_read(self._endpoint.light_color, - ['current_x', 'current_y']) + 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 = (result['current_x'], result['current_y']) + 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 64c6530dd2b17..04216780c8025 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -9,14 +9,14 @@ # 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 @@ def value_added(self): """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 @@ def update_properties(self): 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 @@ def update_properties(self): 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 @@ def turn_on(self, **kwargs): """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 @@ def turn_on(self, **kwargs): 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 new file mode 100644 index 0000000000000..9e87c002482e8 --- /dev/null +++ b/homeassistant/components/linode.py @@ -0,0 +1,98 @@ +""" +Support for Linode. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/linode/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +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'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_CREATED = 'created' +ATTR_NODE_ID = 'node_id' +ATTR_NODE_NAME = 'node_name' +ATTR_IPV4_ADDRESS = 'ipv4_address' +ATTR_IPV6_ADDRESS = 'ipv6_address' +ATTR_MEMORY = 'memory' +ATTR_REGION = 'region' +ATTR_VCPUS = 'vcpus' + +CONF_NODES = 'nodes' + +DATA_LINODE = 'data_li' +LINODE_PLATFORMS = ['binary_sensor', 'switch'] +DOMAIN = 'linode' + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Linode component.""" + import linode + + conf = config[DOMAIN] + access_token = conf.get(CONF_ACCESS_TOKEN) + + _linode = Linode(access_token) + + try: + _LOGGER.info("Linode Profile %s", + _linode.manager.get_profile().username) + except linode.errors.ApiError as _ex: + _LOGGER.error(_ex) + return False + + hass.data[DATA_LINODE] = _linode + + return True + + +class Linode(object): + """Handle all communication with the Linode API.""" + + def __init__(self, access_token): + """Initialize the Linode connection.""" + import linode + + self._access_token = access_token + self.data = None + self.manager = linode.LinodeClient(token=self._access_token) + + def get_node_id(self, node_name): + """Get the status of a Linode Instance.""" + import linode + node_id = None + + try: + all_nodes = self.manager.linode.get_instances() + for node in all_nodes: + if node_name == node.label: + node_id = node.id + except linode.errors.ApiError as _ex: + _LOGGER.error(_ex) + + return node_id + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Use the data from Linode API.""" + import linode + try: + self.data = self.manager.linode.get_instances() + except linode.errors.ApiError as _ex: + _LOGGER.error(_ex) diff --git a/homeassistant/components/lirc.py b/homeassistant/components/lirc.py index 8b9ad0209da56..0cd49ab6c9a32 100644 --- a/homeassistant/components/lirc.py +++ b/homeassistant/components/lirc.py @@ -1,10 +1,10 @@ """ -LIRC interface to receive signals from a infrared remote control. +LIRC interface to receive signals from an infrared remote control. For more details about this component, please refer to the documentation at https://home-assistant.io/components/lirc/ """ -# pylint: disable=import-error +# pylint: disable=import-error,no-member import threading import time import logging diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index c64f77b3bd6ae..b3e4ac8f0ff6a 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -8,11 +8,9 @@ from datetime import timedelta import functools as ft import logging -import os import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity @@ -20,7 +18,7 @@ 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' @@ -41,8 +39,16 @@ vol.Optional(ATTR_CODE): cv.string, }) +# Bitfield of features supported by the lock entity +SUPPORT_OPEN = 1 + _LOGGER = logging.getLogger(__name__) +PROP_TO_ATTR = { + 'changed_by': ATTR_CHANGED_BY, + 'code_format': ATTR_CODE_FORMAT, +} + @bind_hass def is_locked(hass, entity_id=None): @@ -75,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.""" @@ -90,38 +108,31 @@ def async_handle_lock_service(service): code = service.data.get(ATTR_CODE) + update_tasks = [] 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) - update_tasks = [] - - for entity in target_locks: if not entity.should_poll: continue - - update_coro = hass.async_add_job( - entity.async_update_ha_state(True)) - if hasattr(entity, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(entity.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_UNLOCK, async_handle_lock_service, - descriptions.get(SERVICE_UNLOCK), schema=LOCK_SERVICE_SCHEMA) + schema=LOCK_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_LOCK, async_handle_lock_service, - descriptions.get(SERVICE_LOCK), schema=LOCK_SERVICE_SCHEMA) + schema=LOCK_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_OPEN, async_handle_lock_service, + schema=LOCK_SERVICE_SCHEMA) return True @@ -167,15 +178,25 @@ def async_unlock(self, **kwargs): """ 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.""" - if self.code_format is None: - return None - state_attr = { - ATTR_CODE_FORMAT: self.code_format, - ATTR_CHANGED_BY: self.changed_by - } + state_attr = {} + for prop, attr in PROP_TO_ATTR.items(): + value = getattr(self, prop) + if value is not None: + state_attr[attr] = value return state_attr @property diff --git a/homeassistant/components/lock/abode.py b/homeassistant/components/lock/abode.py index aad720e0d7d38..2d3423266360d 100644 --- a/homeassistant/components/lock/abode.py +++ b/homeassistant/components/lock/abode.py @@ -6,7 +6,7 @@ """ import logging -from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN from homeassistant.components.lock import LockDevice @@ -19,22 +19,23 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Abode lock devices.""" import abodepy.helpers.constants as CONST - abode = hass.data[DATA_ABODE] + data = hass.data[ABODE_DOMAIN] - sensors = [] - for sensor in abode.get_devices(type_filter=(CONST.DEVICE_DOOR_LOCK)): - sensors.append(AbodeLock(abode, sensor)) + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_LOCK): + if data.is_excluded(device): + continue - add_devices(sensors) + devices.append(AbodeLock(data, device)) + + data.devices.extend(devices) + + add_devices(devices) class AbodeLock(AbodeDevice, LockDevice): """Representation of an Abode lock.""" - def __init__(self, controller, device): - """Initialize the Abode device.""" - AbodeDevice.__init__(self, controller, device) - def lock(self, **kwargs): """Lock the device.""" self._device.lock() diff --git a/homeassistant/components/lock/august.py b/homeassistant/components/lock/august.py new file mode 100644 index 0000000000000..9ca63cb493bc7 --- /dev/null +++ b/homeassistant/components/lock/august.py @@ -0,0 +1,82 @@ +""" +Support for August lock. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.august/ +""" +from datetime import timedelta + +from homeassistant.components.august import DATA_AUGUST +from homeassistant.components.lock import LockDevice +from homeassistant.const import ATTR_BATTERY_LEVEL + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up August locks.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for lock in data.locks: + devices.append(AugustLock(data, lock)) + + add_devices(devices, True) + + +class AugustLock(LockDevice): + """Representation of an August lock.""" + + def __init__(self, data, lock): + """Initialize the lock.""" + self._data = data + self._lock = lock + self._lock_status = None + self._lock_detail = None + self._changed_by = None + + def lock(self, **kwargs): + """Lock the device.""" + self._data.lock(self._lock.device_id) + + def unlock(self, **kwargs): + """Unlock the device.""" + self._data.unlock(self._lock.device_id) + + def update(self): + """Get the latest state of the sensor.""" + self._lock_status = self._data.get_lock_status(self._lock.device_id) + self._lock_detail = self._data.get_lock_detail(self._lock.device_id) + + from august.activity import ActivityType + activity = self._data.get_latest_device_activity( + self._lock.device_id, + ActivityType.LOCK_OPERATION) + + if activity is not None: + self._changed_by = activity.operated_by + + @property + def name(self): + """Return the name of this device.""" + return self._lock.device_name + + @property + def is_locked(self): + """Return true if device is on.""" + from august.lock import LockStatus + return self._lock_status is LockStatus.LOCKED + + @property + def changed_by(self): + """Last change triggered by.""" + return self._changed_by + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return { + ATTR_BATTERY_LEVEL: self._lock_detail.battery_level, + } diff --git a/homeassistant/components/lock/bmw_connected_drive.py b/homeassistant/components/lock/bmw_connected_drive.py new file mode 100644 index 0000000000000..52734b1259ca4 --- /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 aca25e7e16d44..d561dd333ab31 100644 --- a/homeassistant/components/lock/demo.py +++ b/homeassistant/components/lock/demo.py @@ -4,7 +4,7 @@ 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 @@ def unlock(self, **kwargs): """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 0000000000000..0d70849e37e41 --- /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/isy994.py b/homeassistant/components/lock/isy994.py index edbb8a34f240f..50371fdc9ae8f 100644 --- a/homeassistant/components/lock/isy994.py +++ b/homeassistant/components/lock/isy994.py @@ -8,7 +8,8 @@ from typing import Callable # noqa from homeassistant.components.lock import LockDevice, DOMAIN -import homeassistant.components.isy994 as isy +from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, + ISYDevice) from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN from homeassistant.helpers.typing import ConfigType @@ -19,43 +20,27 @@ 100: STATE_LOCKED } -UOM = ['11'] -STATES = [STATE_LOCKED, STATE_UNLOCKED] - # pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 lock platform.""" - if isy.ISY is None or not isy.ISY.connected: - _LOGGER.error("A connection has not been made to the ISY controller") - return False - devices = [] - - for node in isy.filter_nodes(isy.NODES, units=UOM, - states=STATES): + for node in hass.data[ISY994_NODES][DOMAIN]: devices.append(ISYLockDevice(node)) - for program in isy.PROGRAMS.get(DOMAIN, []): - try: - status = program[isy.KEY_STATUS] - actions = program[isy.KEY_ACTIONS] - assert actions.dtype == 'program', 'Not a program' - except (KeyError, AssertionError): - pass - else: - devices.append(ISYLockProgram(program.name, status, actions)) + for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]: + devices.append(ISYLockProgram(name, status, actions)) add_devices(devices) -class ISYLockDevice(isy.ISYDevice, LockDevice): +class ISYLockDevice(ISYDevice, LockDevice): """Representation of an ISY994 lock device.""" def __init__(self, node) -> None: """Initialize the ISY994 lock device.""" - isy.ISYDevice.__init__(self, node) + super().__init__(node) self._conn = node.parent.parent.conn @property @@ -66,6 +51,8 @@ def is_locked(self) -> bool: @property def state(self) -> str: """Get the state of the lock.""" + if self.is_unknown(): + return None return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) def lock(self, **kwargs) -> None: @@ -98,7 +85,7 @@ class ISYLockProgram(ISYLockDevice): def __init__(self, name: str, node, actions) -> None: """Initialize the lock.""" - ISYLockDevice.__init__(self, node) + super().__init__(node) self._name = name self._actions = actions diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index de14d21a09b71..d8af22cd5c3cb 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -12,7 +12,9 @@ from homeassistant.core import callback from homeassistant.components.lock import LockDevice from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) + CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + MqttAvailability) from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE) import homeassistant.components.mqtt as mqtt @@ -36,12 +38,15 @@ vol.Optional(CONF_PAYLOAD_UNLOCK, default=DEFAULT_PAYLOAD_UNLOCK): 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): """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 @@ -56,15 +61,21 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_PAYLOAD_UNLOCK), config.get(CONF_OPTIMISTIC), value_template, + config.get(CONF_AVAILABILITY_TOPIC), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE) )]) -class MqttLock(LockDevice): +class MqttLock(MqttAvailability, LockDevice): """Representation of a lock that can be toggled using MQTT.""" def __init__(self, name, state_topic, command_topic, qos, retain, - payload_lock, payload_unlock, optimistic, value_template): + payload_lock, payload_unlock, optimistic, value_template, + availability_topic, payload_available, payload_not_available): """Initialize the lock.""" + super().__init__(availability_topic, qos, payload_available, + payload_not_available) self._state = False self._name = name self._state_topic = state_topic @@ -78,10 +89,9 @@ def __init__(self, name, state_topic, command_topic, qos, retain, @asyncio.coroutine def async_added_to_hass(self): - """Subscribe to MQTT events. + """Subscribe to MQTT events.""" + yield from super().async_added_to_hass() - This method is a coroutine. - """ @callback def message_received(topic, payload, qos): """Handle new MQTT messages.""" @@ -93,7 +103,7 @@ def message_received(topic, payload, qos): elif payload == self._payload_unlock: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._state_topic is None: # Force into optimistic mode. @@ -134,7 +144,7 @@ def async_lock(self, **kwargs): if self._optimistic: # Optimistically assume that switch has changed state. self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_unlock(self, **kwargs): @@ -148,4 +158,4 @@ def async_unlock(self, **kwargs): if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/lock/nuki.py b/homeassistant/components/lock/nuki.py index b47305fa227fc..4fe05279919a6 100644 --- a/homeassistant/components/lock/nuki.py +++ b/homeassistant/components/lock/nuki.py @@ -7,15 +7,13 @@ import asyncio from datetime import timedelta import logging -from os import path import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.components.lock import (DOMAIN, LockDevice, PLATFORM_SCHEMA) -from homeassistant.config import load_yaml_config_file +from homeassistant.components.lock import DOMAIN, PLATFORM_SCHEMA, LockDevice from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, CONF_TOKEN) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import extract_entity_ids REQUIREMENTS = ['pynuki==1.3.1'] @@ -27,7 +25,12 @@ ATTR_BATTERY_CRITICAL = 'battery_critical' ATTR_NUKI_ID = 'nuki_id' ATTR_UNLATCH = 'unlatch' + +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=5) +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) + NUKI_DATA = 'nuki' + SERVICE_LOCK_N_GO = 'nuki_lock_n_go' SERVICE_UNLATCH = 'nuki_unlatch' @@ -46,9 +49,6 @@ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids }) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) -MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=5) - # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): @@ -75,15 +75,12 @@ def service_handler(service): elif service.service == SERVICE_UNLATCH: lock.unlatch() - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - hass.services.register( DOMAIN, SERVICE_LOCK_N_GO, service_handler, - descriptions.get(SERVICE_LOCK_N_GO), schema=LOCK_N_GO_SERVICE_SCHEMA) + schema=LOCK_N_GO_SERVICE_SCHEMA) hass.services.register( DOMAIN, SERVICE_UNLATCH, service_handler, - descriptions.get(SERVICE_UNLATCH), schema=UNLATCH_SERVICE_SCHEMA) + schema=UNLATCH_SERVICE_SCHEMA) class NukiLock(LockDevice): @@ -98,7 +95,7 @@ def __init__(self, nuki_lock): @asyncio.coroutine def async_added_to_hass(self): - """Callback when entity is added to hass.""" + """Call when entity is added to hass.""" if NUKI_DATA not in self.hass.data: self.hass.data[NUKI_DATA] = {} if DOMAIN not in self.hass.data[NUKI_DATA]: diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index 04e9f458f9cb6..0b4688c02a209 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -1,132 +1,134 @@ -clear_usercode: - description: Clear a usercode from lock +# Describes the format for available lock services +clear_usercode: + description: Clear a usercode from lock. fields: node_id: - description: Node id of the lock + description: Node id of the lock. example: 18 code_slot: - description: Code slot to clear code from + description: Code slot to clear code from. example: 1 get_usercode: - description: Retrieve a usercode from lock - + description: Retrieve a usercode from lock. fields: node_id: - description: Node id of the lock + description: Node id of the lock. example: 18 code_slot: - description: Code slot to retrive a code from + description: Code slot to retrieve a code from. example: 1 nuki_lock_n_go: - description: "Lock 'n' Go" - + description: "Nuki Lock 'n' Go" fields: entity_id: - description: Entity id of the Nuki lock + description: Entity id of the Nuki lock. example: 'lock.front_door' unlatch: - description: Whether to unlatch the lock + description: Whether to unlatch the lock. example: false nuki_unlatch: - description: "Unlatch" - + description: Nuki unlatch. fields: entity_id: - description: Entity id of the Nuki lock + description: Entity id of the Nuki lock. example: 'lock.front_door' lock: - description: Lock all or specified locks - + description: Lock all or specified locks. fields: entity_id: - description: Name of lock to lock + description: Name of lock to lock. example: 'lock.front_door' code: - description: An optional code to lock the lock with + description: An optional code to lock the lock with. example: 1234 set_usercode: - description: Set a usercode to lock - + description: Set a usercode to lock. fields: node_id: - description: Node id of the lock + description: Node id of the lock. example: 18 code_slot: - description: Code slot to set the code + description: Code slot to set the code. example: 1 usercode: - description: Code to set + description: Code to set. example: 1234 unlock: - description: Unlock all or specified locks - + description: Unlock all or specified locks. fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' code: - description: An optional code to unlock the lock with + description: An optional code to unlock the lock with. example: 1234 wink_set_lock_vacation_mode: description: Set vacation mode for all or specified locks. Disables all user codes. - fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' enabled: - description: enable or disable. true or false. + description: enable or disable. true or false. example: true wink_set_lock_alarm_mode: description: Set alarm mode for all or specified locks. - fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' mode: - description: One of tamper, activity, or forced_entry + description: One of tamper, activity, or forced_entry. example: tamper wink_set_lock_alarm_sensitivity: description: Set alarm sensitivity for all or specified locks. - fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' sensitivity: - description: One of low, medium_low, medium, medium_high, high + description: One of low, medium_low, medium, medium_high, high. example: medium wink_set_lock_alarm_state: description: Set alarm state. - fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' enabled: - description: enable or disable. true or false. + description: enable or disable. true or false. example: true wink_set_lock_beeper_state: description: Set beeper state. - fields: entity_id: - description: Name of lock to unlock + description: Name of lock to unlock. example: 'lock.front_door' enabled: - description: enable or disable. true or false. + description: enable or disable. true or false. example: true +wink_add_new_lock_key_code: + description: Add a new user key code. + fields: + entity_id: + description: Name of lock to unlock. + example: 'lock.front_door' + name: + description: name of the new key code. + example: Bob + code: + description: new key code, length must match length of other codes. Default length is 4. + example: 1234 diff --git a/homeassistant/components/lock/sesame.py b/homeassistant/components/lock/sesame.py index 02b049618d207..5bc404354860f 100644 --- a/homeassistant/components/lock/sesame.py +++ b/homeassistant/components/lock/sesame.py @@ -25,46 +25,53 @@ # pylint: disable=unused-argument -def setup_platform(hass, config: ConfigType, - add_devices: Callable[[list], None], discovery_info=None): +def setup_platform( + hass, config: ConfigType, + add_devices: Callable[[list], None], discovery_info=None): """Set up the Sesame platform.""" import pysesame email = config.get(CONF_EMAIL) password = config.get(CONF_PASSWORD) - add_devices([SesameDevice(sesame) for - sesame in pysesame.get_sesames(email, password)]) + add_devices([SesameDevice(sesame) for sesame in + pysesame.get_sesames(email, password)], + update_before_add=True) class SesameDevice(LockDevice): """Representation of a Sesame device.""" - _sesame = None - def __init__(self, sesame: object) -> None: """Initialize the Sesame device.""" self._sesame = sesame + # Cached properties from pysesame object. + self._device_id = None + self._nickname = None + self._is_unlocked = False + self._api_enabled = False + self._battery = -1 + @property def name(self) -> str: """Return the name of the device.""" - return self._sesame.nickname + return self._nickname @property def available(self) -> bool: """Return True if entity is available.""" - return self._sesame.api_enabled + return self._api_enabled @property def is_locked(self) -> bool: """Return True if the device is currently locked, else False.""" - return not self._sesame.is_unlocked + return not self._is_unlocked @property def state(self) -> str: """Get the state of the device.""" - if self._sesame.is_unlocked: + if self._is_unlocked: return STATE_UNLOCKED return STATE_LOCKED @@ -79,11 +86,16 @@ def unlock(self, **kwargs) -> None: def update(self) -> None: """Update the internal state of the device.""" self._sesame.update_state() + self._nickname = self._sesame.nickname + self._api_enabled = self._sesame.api_enabled + self._is_unlocked = self._sesame.is_unlocked + self._device_id = self._sesame.device_id + self._battery = self._sesame.battery @property def device_state_attributes(self) -> dict: """Return the state attributes.""" attributes = {} - attributes[ATTR_DEVICE_ID] = self._sesame.device_id - attributes[ATTR_BATTERY_LEVEL] = self._sesame.battery + attributes[ATTR_DEVICE_ID] = self._device_id + attributes[ATTR_BATTERY_LEVEL] = self._battery return attributes diff --git a/homeassistant/components/lock/tesla.py b/homeassistant/components/lock/tesla.py index 3e93e4787a0d5..4d24ed2000359 100644 --- a/homeassistant/components/lock/tesla.py +++ b/homeassistant/components/lock/tesla.py @@ -7,7 +7,8 @@ import logging from homeassistant.components.lock import ENTITY_ID_FORMAT, LockDevice -from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN +from homeassistant.components.tesla import TeslaDevice from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED _LOGGER = logging.getLogger(__name__) @@ -26,23 +27,20 @@ class TeslaLock(TeslaDevice, LockDevice): """Representation of a Tesla door lock.""" def __init__(self, tesla_device, controller): - """Initialisation of the lock.""" + """Initialise of the lock.""" self._state = None super().__init__(tesla_device, controller) - self._name = self.tesla_device.name self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) def lock(self, **kwargs): """Send the lock command.""" _LOGGER.debug("Locking doors for: %s", self._name) self.tesla_device.lock() - self._state = STATE_LOCKED def unlock(self, **kwargs): """Send the unlock command.""" _LOGGER.debug("Unlocking doors for: %s", self._name) self.tesla_device.unlock() - self._state = STATE_UNLOCKED @property def is_locked(self): @@ -50,7 +48,7 @@ def is_locked(self): return self._state == STATE_LOCKED def update(self): - """Updating state of the lock.""" + """Update state of the lock.""" _LOGGER.debug("Updating state for: %s", self._name) self.tesla_device.update() self._state = STATE_LOCKED if self.tesla_device.is_locked() \ diff --git a/homeassistant/components/lock/vera.py b/homeassistant/components/lock/vera.py index 04962566821b5..b3aae5e159faf 100644 --- a/homeassistant/components/lock/vera.py +++ b/homeassistant/components/lock/vera.py @@ -19,8 +19,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return Vera locks.""" add_devices( - VeraLock(device, VERA_CONTROLLER) for - device in VERA_DEVICES['lock']) + VeraLock(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['lock']) class VeraLock(VeraDevice, LockDevice): diff --git a/homeassistant/components/lock/wink.py b/homeassistant/components/lock/wink.py index 020fc00ab9a59..1c42e427a00c0 100644 --- a/homeassistant/components/lock/wink.py +++ b/homeassistant/components/lock/wink.py @@ -6,15 +6,14 @@ """ import asyncio import logging -from os import path import voluptuous as vol from homeassistant.components.lock import LockDevice -from homeassistant.components.wink import WinkDevice, DOMAIN +from homeassistant.components.wink import DOMAIN, WinkDevice +from homeassistant.const import ( + ATTR_CODE, ATTR_ENTITY_ID, ATTR_NAME, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN -from homeassistant.config import load_yaml_config_file DEPENDENCIES = ['wink'] @@ -25,18 +24,25 @@ SERVICE_SET_ALARM_SENSITIVITY = 'wink_set_lock_alarm_sensitivity' SERVICE_SET_ALARM_STATE = 'wink_set_lock_alarm_state' SERVICE_SET_BEEPER_STATE = 'wink_set_lock_beeper_state' +SERVICE_ADD_KEY = 'wink_add_new_lock_key_code' ATTR_ENABLED = 'enabled' ATTR_SENSITIVITY = 'sensitivity' ATTR_MODE = 'mode' -ALARM_SENSITIVITY_MAP = {"low": 0.2, "medium_low": 0.4, - "medium": 0.6, "medium_high": 0.8, - "high": 1.0} +ALARM_SENSITIVITY_MAP = { + 'low': 0.2, + 'medium_low': 0.4, + 'medium': 0.6, + 'medium_high': 0.8, + 'high': 1.0, +} -ALARM_MODES_MAP = {"tamper": "tamper", - "activity": "alert", - "forced_entry": "forced_entry"} +ALARM_MODES_MAP = { + 'activity': 'alert', + 'forced_entry': 'forced_entry', + 'tamper': 'tamper', +} SET_ENABLED_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, @@ -53,6 +59,12 @@ vol.Required(ATTR_MODE): vol.In(ALARM_MODES_MAP) }) +ADD_KEY_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_CODE): cv.positive_int, +}) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Wink platform.""" @@ -64,7 +76,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([WinkLockDevice(lock, hass)]) def service_handle(service): - """Handler for services.""" + """Handle for services.""" entity_ids = service.data.get('entity_id') all_locks = hass.data[DOMAIN]['entities']['lock'] locks_to_set = [] @@ -86,42 +98,42 @@ def service_handle(service): lock.set_alarm_mode(service.data.get(ATTR_MODE)) elif service.service == SERVICE_SET_ALARM_SENSITIVITY: lock.set_alarm_sensitivity(service.data.get(ATTR_SENSITIVITY)) - - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) + elif service.service == SERVICE_ADD_KEY: + name = service.data.get(ATTR_NAME) + code = service.data.get(ATTR_CODE) + lock.add_new_key(code, name) hass.services.register(DOMAIN, SERVICE_SET_VACATION_MODE, service_handle, - descriptions.get(SERVICE_SET_VACATION_MODE), schema=SET_ENABLED_SCHEMA) hass.services.register(DOMAIN, SERVICE_SET_ALARM_STATE, service_handle, - descriptions.get(SERVICE_SET_ALARM_STATE), schema=SET_ENABLED_SCHEMA) hass.services.register(DOMAIN, SERVICE_SET_BEEPER_STATE, service_handle, - descriptions.get(SERVICE_SET_BEEPER_STATE), schema=SET_ENABLED_SCHEMA) hass.services.register(DOMAIN, SERVICE_SET_ALARM_MODE, service_handle, - descriptions.get(SERVICE_SET_ALARM_MODE), schema=SET_ALARM_MODES_SCHEMA) hass.services.register(DOMAIN, SERVICE_SET_ALARM_SENSITIVITY, service_handle, - descriptions.get(SERVICE_SET_ALARM_SENSITIVITY), schema=SET_SENSITIVITY_SCHEMA) + hass.services.register(DOMAIN, SERVICE_ADD_KEY, + service_handle, + schema=ADD_KEY_SCHEMA) + class WinkLockDevice(WinkDevice, LockDevice): """Representation of a Wink lock.""" @asyncio.coroutine def async_added_to_hass(self): - """Callback when entity is added to hass.""" + """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['lock'].append(self) @property @@ -149,6 +161,10 @@ def set_beeper_state(self, enabled): """Set lock's beeper mode.""" self.wink.set_beeper_mode(enabled) + def add_new_key(self, code, name): + """Add a new user key code.""" + self.wink.add_new_key(code, name) + def set_alarm_sensitivity(self, sensitivity): """ Set lock's alarm sensitivity. @@ -176,14 +192,14 @@ def device_state_attributes(self): super_attrs = super().device_state_attributes sensitivity = dict_value_to_key(ALARM_SENSITIVITY_MAP, self.wink.alarm_sensitivity()) - super_attrs['alarm sensitivity'] = sensitivity - super_attrs['vacation mode'] = self.wink.vacation_mode_enabled() - super_attrs['beeper mode'] = self.wink.beeper_enabled() - super_attrs['auto lock'] = self.wink.auto_lock_enabled() + super_attrs['alarm_sensitivity'] = sensitivity + super_attrs['vacation_mode'] = self.wink.vacation_mode_enabled() + super_attrs['beeper_mode'] = self.wink.beeper_enabled() + super_attrs['auto_lock'] = self.wink.auto_lock_enabled() alarm_mode = dict_value_to_key(ALARM_MODES_MAP, self.wink.alarm_mode()) - super_attrs['alarm mode'] = alarm_mode - super_attrs['alarm enabled'] = self.wink.alarm_enabled() + super_attrs['alarm_mode'] = alarm_mode + super_attrs['alarm_enabled'] = self.wink.alarm_enabled() return super_attrs diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 009d4cf1069bb..8f39d440caed8 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -8,13 +8,11 @@ # pylint: disable=import-error import asyncio import logging -from os import path import voluptuous as vol from homeassistant.components.lock import DOMAIN, LockDevice from homeassistant.components import zwave -from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -51,6 +49,7 @@ LOCK_ALARM_TYPE = { '9': 'Deadbolt Jammed', + '16': 'Unlocked by Bluetooth ', '18': 'Locked with Keypad by user ', '19': 'Unlocked with Keypad by user ', '21': 'Manually Locked ', @@ -62,6 +61,7 @@ '112': 'Master code changed or User added: ', '113': 'Duplicate Pin-code: ', '130': 'RF module, power restored', + '144': 'Unlocked by NFC Tag or Card by user ', '161': 'Tamper Alarm: ', '167': 'Low Battery', '168': 'Critical Battery Level', @@ -100,7 +100,8 @@ '19', '33', '112', - '113' + '113', + '144' ] SET_USERCODE_SCHEMA = vol.Schema({ @@ -126,8 +127,6 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): yield from zwave.async_setup_platform( hass, config, async_add_devices, discovery_info) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) network = hass.data[zwave.const.DATA_NETWORK] def set_usercode(service): @@ -184,13 +183,13 @@ def clear_usercode(service): hass.services.async_register( DOMAIN, SERVICE_SET_USERCODE, set_usercode, - descriptions.get(SERVICE_SET_USERCODE), schema=SET_USERCODE_SCHEMA) + schema=SET_USERCODE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_GET_USERCODE, get_usercode, - descriptions.get(SERVICE_GET_USERCODE), schema=GET_USERCODE_SCHEMA) + schema=GET_USERCODE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_CLEAR_USERCODE, clear_usercode, - descriptions.get(SERVICE_CLEAR_USERCODE), schema=CLEAR_USERCODE_SCHEMA) + schema=CLEAR_USERCODE_SCHEMA) def get_device(node, values, **kwargs): diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 4facf1334c67a..1ea0b586d3366 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -4,58 +4,57 @@ 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.frontend import register_built_in_panel 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) -GROUP_BY_MINUTES = 15 - -CONTINUOUS_DOMAINS = ['proximity', 'sensor'] - -ATTR_NAME = 'name' -ATTR_MESSAGE = 'message' -ATTR_DOMAIN = 'domain' -ATTR_ENTITY_ID = 'entity_id' +ALL_EVENT_TYPES = [ + EVENT_STATE_CHANGED, EVENT_LOGBOOK_ENTRY, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +] LOG_MESSAGE_SCHEMA = vol.Schema({ vol.Required(ATTR_NAME): cv.string, @@ -84,7 +83,7 @@ def async_log_entry(hass, name, message, domain=None, entity_id=None): hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, data) -def setup(hass, config): +async def setup(hass, config): """Listen for download events to download files.""" @callback def log_message(service): @@ -100,10 +99,10 @@ def log_message(service): hass.http.register_view(LogbookView(config.get(DOMAIN, {}))) - register_built_in_panel( - hass, 'logbook', 'Logbook', 'mdi:format-list-bulleted-type') + await hass.components.frontend.async_register_built_in_panel( + 'logbook', 'logbook', 'mdi:format-list-bulleted-type') - hass.services.register( + hass.services.async_register( DOMAIN, 'log', log_message, schema=LOG_MESSAGE_SCHEMA) return True @@ -116,11 +115,10 @@ class LogbookView(HomeAssistantView): extra_urls = ['/api/logbook/{datetime}'] def __init__(self, config): - """Initilalize the logbook view.""" + """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) @@ -134,10 +132,12 @@ def get(self, request, datetime=None): end_day = start_day + timedelta(days=1) hass = request.app['hass'] - events = yield from hass.async_add_job( - _get_events, hass, start_day, end_day) - events = _exclude_events(events, self.config) - return self.json(humanify(events)) + 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): @@ -170,6 +170,8 @@ def humanify(events): - if 2+ sensor updates in GROUP_BY_MINUTES, show last - if home assistant stop and start happen in same minute call it restarted """ + domain_prefixes = tuple('{}.'.format(dom) for dom in CONTINUOUS_DOMAINS) + # Group events in batches of GROUP_BY_MINUTES for _, g_events in groupby( events, @@ -189,11 +191,7 @@ def humanify(events): if event.event_type == EVENT_STATE_CHANGED: entity_id = event.data.get('entity_id') - if entity_id is None: - continue - - if entity_id.startswith(tuple('{}.'.format( - domain) for domain in CONTINUOUS_DOMAINS)): + if entity_id.startswith(domain_prefixes): last_sensor_event[entity_id] = event elif event.event_type == EVENT_HOMEASSISTANT_STOP: @@ -214,14 +212,6 @@ def humanify(events): to_state = State.from_dict(event.data.get('new_state')) - # If last_changed != last_updated only attributes have changed - # we do not report on that yet. Also filter auto groups. - if not to_state or \ - to_state.last_changed != to_state.last_updated or \ - to_state.domain == 'group' and \ - to_state.attributes.get('auto', False): - continue - domain = to_state.domain # Skip all but the last sensor state @@ -274,22 +264,26 @@ def humanify(events): entity_id) -def _get_events(hass, start_day, end_day): +def _get_events(hass, config, start_day, end_day): """Get events for a period of time.""" - from homeassistant.components.recorder.models import Events + from homeassistant.components.recorder.models import Events, States from homeassistant.components.recorder.util import ( execute, session_scope) with session_scope(hass=hass) as session: - query = session.query(Events).order_by( - Events.time_fired).filter( - (Events.time_fired > start_day) & - (Events.time_fired < end_day)) - return execute(query) + query = session.query(Events).order_by(Events.time_fired) \ + .outerjoin(States, (Events.event_id == States.event_id)) \ + .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \ + .filter((Events.time_fired > start_day) + & (Events.time_fired < end_day)) \ + .filter((States.last_updated == States.last_changed) + | (States.state_id.is_(None))) + events = execute(query) + return humanify(_exclude_events(events, config)) def _exclude_events(events, config): - """Get lists of excluded entities and platforms.""" + """Get list of filtered events.""" excluded_entities = [] excluded_domains = [] included_entities = [] @@ -308,23 +302,41 @@ def _exclude_events(events, config): domain, entity_id = None, None if event.event_type == EVENT_STATE_CHANGED: - to_state = State.from_dict(event.data.get('new_state')) + entity_id = event.data.get('entity_id') + + if entity_id is None: + continue + # Do not report on new entities if event.data.get('old_state') is None: continue + new_state = event.data.get('new_state') + # Do not report on entity removal - if not to_state: + if not new_state: + continue + + attributes = new_state.get('attributes', {}) + + # If last_changed != last_updated only attributes have changed + # we do not report on that yet. + last_changed = new_state.get('last_changed') + last_updated = new_state.get('last_updated') + if last_changed != last_updated: + continue + + domain = split_entity_id(entity_id)[0] + + # Also filter auto groups. + if domain == 'group' and attributes.get('auto', False): continue # exclude entities which are customized hidden - hidden = to_state.attributes.get(ATTR_HIDDEN, False) + hidden = attributes.get(ATTR_HIDDEN, False) if hidden: continue - domain = to_state.domain - entity_id = to_state.entity_id - elif event.event_type == EVENT_LOGBOOK_ENTRY: domain = event.data.get(ATTR_DOMAIN) entity_id = event.data.get(ATTR_ENTITY_ID) diff --git a/homeassistant/components/logger.py b/homeassistant/components/logger.py index 6b79bd40987fa..6e8995a0444cd 100644 --- a/homeassistant/components/logger.py +++ b/homeassistant/components/logger.py @@ -4,14 +4,11 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/logger/ """ -import asyncio import logging -import os from collections import OrderedDict import voluptuous as vol -from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv DOMAIN = 'logger' @@ -75,8 +72,7 @@ def filter(self, record): return record.levelno >= default -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the logger component.""" logfilter = {} @@ -95,7 +91,7 @@ def set_log_levels(logpoints): if LOGGER_LOGS in logfilter: logs.update(logfilter[LOGGER_LOGS]) - # Add new logpoints mapped to correc severity + # Add new logpoints mapped to correct severity for key, value in logpoints.items(): logs[key] = LOGSEVERITY[value] @@ -118,18 +114,12 @@ def set_log_levels(logpoints): 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) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_SET_LEVEL, async_service_handler, - descriptions[DOMAIN].get(SERVICE_SET_LEVEL), schema=SERVICE_SET_LEVEL_SCHEMA) return True diff --git a/homeassistant/components/lutron.py b/homeassistant/components/lutron.py index 819844325d1f9..bef821220b347 100644 --- a/homeassistant/components/lutron.py +++ b/homeassistant/components/lutron.py @@ -37,7 +37,7 @@ def setup(hass, base_config): from pylutron import Lutron hass.data[LUTRON_CONTROLLER] = None - hass.data[LUTRON_DEVICES] = {'light': []} + hass.data[LUTRON_DEVICES] = {'light': [], 'cover': []} config = base_config.get(DOMAIN) hass.data[LUTRON_CONTROLLER] = Lutron( @@ -50,9 +50,12 @@ def setup(hass, base_config): # Sort our devices into types for area in hass.data[LUTRON_CONTROLLER].areas: for output in area.outputs: - hass.data[LUTRON_DEVICES]['light'].append((area.name, output)) + if output.type == 'SYSTEM_SHADE': + hass.data[LUTRON_DEVICES]['cover'].append((area.name, output)) + else: + hass.data[LUTRON_DEVICES]['light'].append((area.name, output)) - for component in ('light',): + for component in ('light', 'cover'): discovery.load_platform(hass, component, DOMAIN, None, base_config) return True diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index 8660546c910c3..7b1b7417cfd98 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylutron-caseta==0.2.8'] +REQUIREMENTS = ['pylutron-caseta==0.5.0'] _LOGGER = logging.getLogger(__name__) @@ -22,9 +22,16 @@ DOMAIN = 'lutron_caseta' +CONF_KEYFILE = 'keyfile' +CONF_CERTFILE = 'certfile' +CONF_CA_CERTS = 'ca_certs' + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOST): cv.string + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_KEYFILE): cv.string, + vol.Required(CONF_CERTFILE): cv.string, + vol.Required(CONF_CA_CERTS): cv.string }) }, extra=vol.ALLOW_EXTRA) @@ -33,14 +40,21 @@ ] -def setup(hass, base_config): +@asyncio.coroutine +def async_setup(hass, base_config): """Set up the Lutron component.""" from pylutron_caseta.smartbridge import Smartbridge config = base_config.get(DOMAIN) - hass.data[LUTRON_CASETA_SMARTBRIDGE] = Smartbridge( - hostname=config[CONF_HOST] - ) + keyfile = hass.config.path(config[CONF_KEYFILE]) + certfile = hass.config.path(config[CONF_CERTFILE]) + ca_certs = hass.config.path(config[CONF_CA_CERTS]) + bridge = Smartbridge.create_tls(hostname=config[CONF_HOST], + keyfile=keyfile, + certfile=certfile, + ca_certs=ca_certs) + hass.data[LUTRON_CASETA_SMARTBRIDGE] = bridge + yield from bridge.connect() if not hass.data[LUTRON_CASETA_SMARTBRIDGE].is_connected(): _LOGGER.error("Unable to connect to Lutron smartbridge at %s", config[CONF_HOST]) @@ -49,7 +63,8 @@ def setup(hass, base_config): _LOGGER.info("Connected to Lutron smartbridge at %s", config[CONF_HOST]) for component in LUTRON_CASETA_COMPONENTS: - discovery.load_platform(hass, component, DOMAIN, {}, config) + hass.async_add_job(discovery.async_load_platform(hass, component, + DOMAIN, {}, config)) return True @@ -73,13 +88,8 @@ def __init__(self, device, bridge): @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - self.hass.async_add_job( - self._smartbridge.add_subscriber, self._device_id, - self._update_callback - ) - - def _update_callback(self): - self.schedule_update_ha_state() + self._smartbridge.add_subscriber(self._device_id, + self.async_schedule_update_ha_state) @property def name(self): diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 9d73101603579..8ff3746889e42 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -35,8 +35,8 @@ def async_setup(hass, config): """Track states and offer events for mailboxes.""" mailboxes = [] - hass.components.frontend.register_built_in_panel( - 'mailbox', 'Mailbox', 'mdi:mailbox') + yield from hass.components.frontend.async_register_built_in_panel( + 'mailbox', 'mailbox', 'mdi:mailbox') hass.http.register_view(MailboxPlatformsView(mailboxes)) hass.http.register_view(MailboxMessageView(mailboxes)) hass.http.register_view(MailboxMediaView(mailboxes)) @@ -82,7 +82,7 @@ def async_setup_platform(p_type, p_config=None, discovery_info=None): mailbox_entity = MailboxEntity(hass, mailbox) component = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) - yield from component.async_add_entity(mailbox_entity) + yield from component.async_add_entities([mailbox_entity]) setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config in config_per_platform(config, DOMAIN)] @@ -111,7 +111,7 @@ def __init__(self, hass, mailbox): @callback def _mailbox_updated(event): - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) hass.bus.async_listen(EVENT, _mailbox_updated) @@ -133,7 +133,7 @@ def async_update(self): class Mailbox(object): - """Represent an mailbox device.""" + """Represent a mailbox device.""" def __init__(self, hass, name): """Initialize mailbox object.""" diff --git a/homeassistant/components/mailbox/asterisk_mbox.py b/homeassistant/components/mailbox/asterisk_mbox.py index a1953839f4f68..2e807058edfb1 100644 --- a/homeassistant/components/mailbox/asterisk_mbox.py +++ b/homeassistant/components/mailbox/asterisk_mbox.py @@ -30,7 +30,7 @@ class AsteriskMailbox(Mailbox): """Asterisk VM Sensor.""" def __init__(self, hass, name): - """Initialie Asterisk mailbox.""" + """Initialize Asterisk mailbox.""" super().__init__(hass, name) async_dispatcher_connect( self.hass, SIGNAL_MESSAGE_UPDATE, self._update_callback) diff --git a/homeassistant/components/map.py b/homeassistant/components/map.py new file mode 100644 index 0000000000000..30cb00af69ea9 --- /dev/null +++ b/homeassistant/components/map.py @@ -0,0 +1,14 @@ +""" +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/ +""" +DOMAIN = 'map' + + +async def async_setup(hass, config): + """Register the built-in map 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 0000000000000..b2805c994e873 --- /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 a0a8db6ba4d1d..bca7a1b4ab7ec 100644 --- a/homeassistant/components/maxcube.py +++ b/homeassistant/components/maxcube.py @@ -13,7 +13,7 @@ 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 @@ 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 - - host = config.get(DOMAIN).get(CONF_HOST) - port = config.get(DOMAIN).get(CONF_PORT) - - try: - cube = MaxCube(MaxCubeConnection(host, port)) - except timeout: - _LOGGER.error("Connection to Max!Cube could not be established") - cube = None + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} + + 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)) + 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 @@ def update(self): """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 1ecb09ac022bf..89cc296111b80 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -5,26 +5,25 @@ https://home-assistant.io/components/media_extractor/ """ import logging -import os + import voluptuous as vol from homeassistant.components.media_player import ( ATTR_ENTITY_ID, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as MEDIA_PLAYER_DOMAIN, MEDIA_PLAYER_PLAY_MEDIA_SCHEMA, SERVICE_PLAY_MEDIA) -from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.9.2'] +REQUIREMENTS = ['youtube_dl==2018.05.09'] _LOGGER = logging.getLogger(__name__) -DOMAIN = 'media_extractor' -DEPENDENCIES = ['media_player'] - CONF_CUSTOMIZE_ENTITIES = 'customize' CONF_DEFAULT_STREAM_QUERY = 'default_query' + DEFAULT_STREAM_QUERY = 'best' +DEPENDENCIES = ['media_player'] +DOMAIN = 'media_extractor' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -37,18 +36,11 @@ def setup(hass, config): """Set up the media extractor service.""" - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), - 'media_player', 'services.yaml')) - def play_media(call): """Get stream URL and send it to the play_media service.""" MediaExtractor(hass, config[DOMAIN], call.data).extract_and_send() - hass.services.register(DOMAIN, - SERVICE_PLAY_MEDIA, - play_media, - description=descriptions[SERVICE_PLAY_MEDIA], + hass.services.register(DOMAIN, SERVICE_PLAY_MEDIA, play_media, schema=MEDIA_PLAYER_PLAY_MEDIA_SCHEMA) return True @@ -93,7 +85,7 @@ def extract_and_send(self): else: entities = self.get_entities() - if len(entities) == 0: + if not entities: self.call_media_player_service(stream_selector, None) for entity_id in entities: @@ -116,7 +108,7 @@ def get_stream_selector(self): _LOGGER.warning( "Playlists are not supported, looking for the first video") entries = list(all_media['entries']) - if len(entries) > 0: + if entries: selected_media = entries[0] else: _LOGGER.error("Playlist is empty") diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 870252cc55e53..20a1a473ba8bf 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -5,34 +5,35 @@ https://home-assistant.io/components/media_player/ """ import asyncio +import base64 from datetime import timedelta import functools as ft +import collections import hashlib import logging -import os from random import SystemRandom from aiohttp import web +from aiohttp.hdrs import CONTENT_TYPE, CACHE_CONTROL import async_timeout import voluptuous as vol -from homeassistant.config import load_yaml_config_file -from homeassistant.loader import bind_hass -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.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, + SERVICE_TOGGLE, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_VOLUME_UP, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, + SERVICE_VOLUME_SET, SERVICE_MEDIA_PAUSE, SERVICE_SHUFFLE_SET, + SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.util.async import run_coroutine_threadsafe -from homeassistant.const import ( - STATE_OFF, STATE_UNKNOWN, STATE_PLAYING, STATE_IDLE, - ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_SET, - SERVICE_VOLUME_MUTE, SERVICE_TOGGLE, SERVICE_MEDIA_STOP, - SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PAUSE, - SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, - SERVICE_SHUFFLE_SET) +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() @@ -44,17 +45,16 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' ENTITY_IMAGE_URL = '/api/media_player_proxy/{0}?token={1}&cache={2}' -ATTR_CACHE_IMAGES = 'images' -ATTR_CACHE_URLS = 'urls' -ATTR_CACHE_MAXSIZE = 'maxsize' +CACHE_IMAGES = 'images' +CACHE_MAXSIZE = 'maxsize' +CACHE_LOCK = 'lock' +CACHE_URL = 'url' +CACHE_CONTENT = 'content' ENTITY_IMAGE_CACHE = { - ATTR_CACHE_IMAGES: {}, - ATTR_CACHE_URLS: [], - ATTR_CACHE_MAXSIZE: 16 + CACHE_IMAGES: collections.OrderedDict(), + CACHE_MAXSIZE: 16 } -CONTENT_TYPE_HEADER = 'Content-Type' - SERVICE_PLAY_MEDIA = 'play_media' SERVICE_SELECT_SOURCE = 'select_source' SERVICE_CLEAR_PLAYLIST = 'clear_playlist' @@ -86,10 +86,12 @@ 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' +MEDIA_TYPE_URL = 'url' SUPPORT_PAUSE = 1 SUPPORT_SEEK = 2 @@ -362,22 +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.http.register_view(MediaPlayerImageView(component.entities)) - - yield from component.async_setup(config) + 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)) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) + 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: @@ -405,27 +412,20 @@ def async_service_handler(service): update_tasks = [] for player in target_players: - yield from getattr(player, method['method'])(**params) - - for player in target_players: + await getattr(player, method['method'])(**params) if not player.should_poll: continue - - update_coro = player.async_update_ha_state(True) - if hasattr(player, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + 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( 'schema', MEDIA_PLAYER_SCHEMA) hass.services.async_register( DOMAIN, service, async_service_handler, - descriptions.get(service), schema=schema) + schema=schema) return True @@ -497,20 +497,18 @@ def media_image_url(self): def media_image_hash(self): """Hash value for media image.""" url = self.media_image_url - if url is not None: - return hashlib.md5(url.encode('utf-8')).hexdigest()[:5] + return hashlib.sha256(url.encode('utf-8')).hexdigest()[:16] 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): @@ -637,11 +635,11 @@ def async_set_volume_level(self, volume): return self.hass.async_add_job(self.set_volume_level, volume) def media_play(self): - """Send play commmand.""" + """Send play command.""" raise NotImplementedError() def async_media_play(self): - """Send play commmand. + """Send play command. This method must be run in the event loop and returns a coroutine. """ @@ -821,34 +819,31 @@ def async_toggle(self): 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): @@ -891,56 +886,42 @@ def state_attributes(self): return state_attr - def preload_media_image_url(self, url): - """Preload and cache a media image for future use.""" - run_coroutine_threadsafe( - _async_fetch_image(self.hass, url), self.hass.loop - ).result() - -@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). """ - cache_images = ENTITY_IMAGE_CACHE[ATTR_CACHE_IMAGES] - cache_urls = ENTITY_IMAGE_CACHE[ATTR_CACHE_URLS] - cache_maxsize = ENTITY_IMAGE_CACHE[ATTR_CACHE_MAXSIZE] - - if url in cache_images: - return cache_images[url] + cache_images = ENTITY_IMAGE_CACHE[CACHE_IMAGES] + cache_maxsize = ENTITY_IMAGE_CACHE[CACHE_MAXSIZE] - content, content_type = (None, None) - websession = async_get_clientsession(hass) - try: - with async_timeout.timeout(10, loop=hass.loop): - response = yield from websession.get(url) + if url not in cache_images: + cache_images[url] = {CACHE_LOCK: asyncio.Lock(loop=hass.loop)} - if response.status == 200: - content = yield from response.read() - content_type = response.headers.get(CONTENT_TYPE_HEADER) - if content_type: - content_type = content_type.split(';')[0] + async with cache_images[url][CACHE_LOCK]: + if CACHE_CONTENT in cache_images[url]: + return cache_images[url][CACHE_CONTENT] - except asyncio.TimeoutError: - pass + content, content_type = (None, None) + websession = async_get_clientsession(hass) + try: + with async_timeout.timeout(10, loop=hass.loop): + response = await websession.get(url) - if not content: - return (None, None) + if response.status == 200: + content = await response.read() + content_type = response.headers.get(CONTENT_TYPE) + if content_type: + content_type = content_type.split(';')[0] + cache_images[url][CACHE_CONTENT] = content, content_type - cache_images[url] = (content, content_type) - cache_urls.append(url) + except asyncio.TimeoutError: + pass - while len(cache_urls) > cache_maxsize: - # remove oldest item from cache - oldest_url = cache_urls[0] - if oldest_url in cache_images: - del cache_images[oldest_url] + while len(cache_images) > cache_maxsize: + cache_images.popitem(last=False) - cache_urls = cache_urls[1:] - - return content, content_type + return content, content_type class MediaPlayerImageView(HomeAssistantView): @@ -950,14 +931,13 @@ class MediaPlayerImageView(HomeAssistantView): url = '/api/media_player_proxy/{entity_id}' name = 'api:media_player:image' - def __init__(self, entities): + def __init__(self, component): """Initialize a media player view.""" - self.entities = entities + self.component = component - @asyncio.coroutine - def get(self, request, entity_id): + async def get(self, request, entity_id): """Start a get request.""" - player = self.entities.get(entity_id) + player = self.component.get_entity(entity_id) if player is None: status = 404 if request[KEY_AUTHENTICATED] else 401 return web.Response(status=status) @@ -968,9 +948,44 @@ def get(self, request, entity_id): 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) - return web.Response(body=data, content_type=content_type) + 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/anthemav.py b/homeassistant/components/media_player/anthemav.py index 293c6e51d5293..474751c2574fe 100644 --- a/homeassistant/components/media_player/anthemav.py +++ b/homeassistant/components/media_player/anthemav.py @@ -49,7 +49,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): def async_anthemav_update_callback(message): """Receive notification from transport that new data exists.""" - _LOGGER.info("Received update calback from AVR: %s", message) + _LOGGER.info("Received update callback from AVR: %s", message) hass.async_add_job(device.async_update_ha_state()) avr = yield from anthemav.Connection.create( diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index 3ecb1c0922ef6..37a50b39e950e 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -25,6 +25,10 @@ _LOGGER = logging.getLogger(__name__) +SUPPORT_APPLE_TV = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | \ + SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SEEK | \ + SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -79,7 +83,7 @@ def name(self): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self.atv.metadata.device_id @property @@ -93,10 +97,11 @@ def state(self): if not self._power.turned_on: return STATE_OFF - if self._playing is not None: + if self._playing: from pyatv import const state = self._playing.play_state - if state == const.PLAY_STATE_NO_MEDIA or \ + if state == const.PLAY_STATE_IDLE or \ + state == const.PLAY_STATE_NO_MEDIA or \ state == const.PLAY_STATE_LOADING: return STATE_IDLE elif state == const.PLAY_STATE_PLAYING: @@ -112,7 +117,7 @@ def state(self): def playstatus_update(self, updater, playing): """Print what is currently playing when it changes.""" self._playing = playing - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def playstatus_error(self, updater, exception): @@ -126,12 +131,12 @@ def playstatus_error(self, updater, exception): # implemented here later. updater.start(initial_delay=10) self._playing = None - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def media_content_type(self): """Content type of current playing media.""" - if self._playing is not None: + if self._playing: from pyatv import const media_type = self._playing.media_type if media_type == const.MEDIA_TYPE_VIDEO: @@ -144,13 +149,13 @@ def media_content_type(self): @property def media_duration(self): """Duration of current playing media in seconds.""" - if self._playing is not None: + if self._playing: return self._playing.total_time @property def media_position(self): """Position of current playing media in seconds.""" - if self._playing is not None: + if self._playing: return self._playing.position @property @@ -168,18 +173,23 @@ def async_play_media(self, media_type, media_id, **kwargs): @property def media_image_hash(self): """Hash value for media image.""" - if self._playing is not None and self.state != STATE_IDLE: + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: return self._playing.hash @asyncio.coroutine def async_get_media_image(self): """Fetch media image of current playing image.""" - return (yield from self.atv.metadata.artwork()), 'image/png' + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: + return (yield from self.atv.metadata.artwork()), 'image/png' + + return None, None @property def media_title(self): """Title of current playing media.""" - if self._playing is not None: + if self._playing: if self.state == STATE_IDLE: return 'Nothing playing' title = self._playing.title @@ -190,14 +200,7 @@ def media_title(self): @property def supported_features(self): """Flag media player features that are supported.""" - features = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA - if self._playing is None or self.state == STATE_IDLE: - return features - - features |= SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SEEK | \ - SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK - - return features + return SUPPORT_APPLE_TV @asyncio.coroutine def async_turn_on(self): @@ -215,7 +218,7 @@ def async_media_play_pause(self): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: state = self.state if state == STATE_PAUSED: return self.atv.remote_control.play() @@ -227,7 +230,7 @@ def async_media_play(self): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.play() def async_media_stop(self): @@ -235,7 +238,7 @@ def async_media_stop(self): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.stop() def async_media_pause(self): @@ -243,7 +246,7 @@ def async_media_pause(self): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.pause() def async_media_next_track(self): @@ -251,7 +254,7 @@ def async_media_next_track(self): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.next() def async_media_previous_track(self): @@ -259,7 +262,7 @@ def async_media_previous_track(self): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.previous() def async_media_seek(self, position): @@ -267,5 +270,5 @@ def async_media_seek(self, position): This method must be run in the event loop and returns a coroutine. """ - if self._playing is not None: + if self._playing: return self.atv.remote_control.set_position(position) diff --git a/homeassistant/components/media_player/aquostv.py b/homeassistant/components/media_player/aquostv.py index ae6d9e04643b1..6933286f0fe58 100644 --- a/homeassistant/components/media_player/aquostv.py +++ b/homeassistant/components/media_player/aquostv.py @@ -201,9 +201,9 @@ def volume_down(self): self._remote.volume(int(self._volume * 60) - 2) @_retry - def set_volume_level(self, level): + def set_volume_level(self, volume): """Set Volume media player.""" - self._remote.volume(int(level * 60)) + self._remote.volume(int(volume * 60)) @_retry def mute_volume(self, mute): diff --git a/homeassistant/components/media_player/blackbird.py b/homeassistant/components/media_player/blackbird.py new file mode 100644 index 0000000000000..1c976f5eecd32 --- /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 c1b9bab693743..283c4af032e63 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -1,63 +1,95 @@ """ -Bluesound. +Support for Bluesound devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.bluesound/ """ -import logging -from datetime import timedelta -from asyncio.futures import CancelledError import asyncio -import voluptuous as vol -from aiohttp.client_exceptions import ClientError +from asyncio.futures import CancelledError +from datetime import timedelta +import logging + import aiohttp +from aiohttp.client_exceptions import ClientError +from aiohttp.hdrs import CONNECTION, KEEP_ALIVE import async_timeout -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.core import callback -from homeassistant.util import Throttle -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.util.dt as dt_util +import voluptuous as vol from homeassistant.components.media_player import ( - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, - SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, - SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC, - SUPPORT_CLEAR_PLAYLIST, SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_STEP) + ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, + MediaPlayerDevice) from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - STATE_PLAYING, STATE_PAUSED, STATE_IDLE, CONF_HOSTS, - CONF_HOST, CONF_PORT, CONF_NAME) + ATTR_ENTITY_ID, CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, + STATE_OFF, STATE_PAUSED, STATE_PLAYING) +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util REQUIREMENTS = ['xmltodict==0.11.0'] -STATE_OFFLINE = 'offline' -ATTR_MODEL = 'model' -ATTR_MODEL_NAME = 'model_name' -ATTR_BRAND = 'brand' +_LOGGER = logging.getLogger(__name__) + +ATTR_MASTER = 'master' 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) -_LOGGER = logging.getLogger(__name__) +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, }]) }) +BS_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +BS_JOIN_SCHEMA = BS_SCHEMA.extend({ + vol.Required(ATTR_MASTER): cv.entity_id, +}) + +SERVICE_TO_METHOD = { + SERVICE_JOIN: { + 'method': 'async_join', + 'schema': BS_JOIN_SCHEMA}, + SERVICE_UNJOIN: { + 'method': 'async_unjoin', + 'schema': BS_SCHEMA}, + SERVICE_SET_TIMER: { + 'method': 'async_increase_timer', + 'schema': BS_SCHEMA}, + SERVICE_CLEAR_TIMER: { + 'method': 'async_clear_timer', + 'schema': BS_SCHEMA} +} + def _add_player(hass, async_add_devices, host, port=None, name=None): + """Add Bluesound players.""" if host in [x.host for x in hass.data[DATA_BLUESOUND]]: return @@ -80,20 +112,15 @@ def _stop_polling(): def _add_player_cb(): """Add player after first sync fetch.""" async_add_devices([player]) - _LOGGER.info('Added Bluesound device with name: %s', player.name) + _LOGGER.info("Added device with name: %s", player.name) if hass.is_running: _start_polling() else: hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, - _start_polling - ) + EVENT_HOMEASSISTANT_START, _start_polling) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, - _stop_polling - ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) player = BluesoundPlayer(hass, host, port, name, _add_player_cb) hass.data[DATA_BLUESOUND].append(player) @@ -101,47 +128,62 @@ def _add_player_cb(): if hass.is_running: _init_player() else: - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, - _init_player - ) + 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] = [] if discovery_info: - _add_player(hass, async_add_devices, discovery_info.get('host'), - discovery_info.get('port', None)) + _add_player(hass, async_add_devices, discovery_info.get(CONF_HOST), + discovery_info.get(CONF_PORT, None)) return hosts = config.get(CONF_HOSTS, None) if hosts: for host in hosts: - _add_player(hass, - async_add_devices, - host.get(CONF_HOST), - host.get(CONF_PORT, None), - host.get(CONF_NAME, None)) + _add_player( + hass, async_add_devices, host.get(CONF_HOST), + host.get(CONF_PORT), host.get(CONF_NAME)) + + async def async_service_handler(service): + """Map services to method of Bluesound devices.""" + method = SERVICE_TO_METHOD.get(service.service) + if not method: + return + + 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_players = [player for player in hass.data[DATA_BLUESOUND] + if player.entity_id in entity_ids] + else: + target_players = hass.data[DATA_BLUESOUND] + + for player in target_players: + await getattr(player, method['method'])(**params) + + for service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[service]['schema'] + hass.services.async_register( + DOMAIN, service, async_service_handler, schema=schema) class BluesoundPlayer(MediaPlayerDevice): - """Bluesound Player Object.""" + """Representation of a Bluesound Player.""" def __init__(self, hass, host, port=None, name=None, init_callback=None): """Initialize the media player.""" self.host = host self._hass = hass - self._port = port + self.port = port self._polling_session = async_get_clientsession(hass) - self._polling_task = None # The actuall polling task. + self._polling_task = None # The actual polling task. self._name = name - self._brand = None - self._model = None - self._model_name = None self._icon = None self._capture_items = [] self._services_items = [] @@ -152,27 +194,33 @@ def __init__(self, hass, host, port=None, name=None, init_callback=None): self._is_online = False self._retry_remove = None self._lastvol = None + self._master = None + self._is_master = False + self._group_name = None + self._init_callback = init_callback - if self._port is None: - self._port = DEFAULT_PORT + if self.port is None: + self.port = DEFAULT_PORT + + class _TimeoutException(Exception): + pass -# Internal methods @staticmethod - def _try_get_index(string, seach_string): + def _try_get_index(string, search_string): + """Get the index.""" try: - return string.index(seach_string) + return string.index(search_string) except ValueError: return -1 - @asyncio.coroutine - def _internal_update_sync_status(self, on_updated_cb=None, - raise_timeout=False): + 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( - 'SyncStatus', - raise_timeout, raise_timeout) - except: + resp = await self.send_bluesound_command( + 'SyncStatus', raise_timeout, raise_timeout) + except Exception: raise if not resp: @@ -181,38 +229,48 @@ def _internal_update_sync_status(self, on_updated_cb=None, if not self._name: self._name = self._sync_status.get('@name', self.host) - if not self._brand: - self._brand = self._sync_status.get('@brand', self.host) - if not self._model: - self._model = self._sync_status.get('@model', self.host) if not self._icon: self._icon = self._sync_status.get('@icon', self.host) - if not self._model_name: - self._model_name = self._sync_status.get('@modelName', self.host) + + master = self._sync_status.get('master', None) + if master is not None: + self._is_master = False + master_host = master.get('#text') + master_device = [device for device in + self._hass.data[DATA_BLUESOUND] + if device.host == master_host] + + if master_device and master_host != self.host: + self._master = master_device[0] + else: + self._master = None + _LOGGER.error("Master not found %s", master_host) + else: + if self._master is not None: + self._master = None + slaves = self._sync_status.get('slave', None) + self._is_master = slaves is not None if on_updated_cb: on_updated_cb() return True -# END Internal methods -# Poll functionality - @asyncio.coroutine - def _start_poll_command(self): - """"Loop which polls the status of the player.""" + 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): - _LOGGER.info("Bluesound node %s is offline, retrying later", - self._name) - yield from asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT, - loop=self._hass.loop) + except (asyncio.TimeoutError, ClientError, + BluesoundPlayer._TimeoutException): + _LOGGER.info("Node %s is offline, retrying later", self._name) + await asyncio.sleep( + NODE_OFFLINE_CHECK_TIMEOUT, loop=self._hass.loop) self.start_polling() except CancelledError: - _LOGGER.debug("Stopping bluesound polling of node %s", self._name) - except: + _LOGGER.debug("Stopping the polling of node %s", self._name) + except Exception: _LOGGER.exception("Unexpected error in %s", self._name) raise @@ -224,47 +282,37 @@ def start_polling(self): def stop_polling(self): """Stop the polling task.""" self._polling_task.cancel() -# END Poll functionality -# Initiator - @asyncio.coroutine - def async_init(self): - """Initiate the player async.""" + 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._internal_update_sync_status(self._init_callback, - True) + await self.force_update_sync_status( + self._init_callback, True) except (asyncio.TimeoutError, ClientError): - _LOGGER.info("Bluesound node %s is offline, retrying later", - self.host) + _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: - _LOGGER.exception("Unexpected when initiating error in %s", - self.host) + self._hass, self.async_init, NODE_RETRY_INITIATION) + except Exception: + _LOGGER.exception( + "Unexpected when initiating error in %s", self.host) raise -# END Initiator -# Status updates fetchers - @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 @@ -273,39 +321,41 @@ def send_bluesound_command(self, method, raise_timeout=False, if method[0] == '/': method = method[1:] - url = "http://{}:{}/{}".format(self.host, self._port, method) + url = "http://{}:{}/{}".format(self.host, self.port, method) - _LOGGER.info("calling URL: %s", url) + _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 except (asyncio.TimeoutError, aiohttp.ClientError): if raise_timeout: - _LOGGER.info("Timeout with Bluesound: %s", self.host) + _LOGGER.info("Timeout: %s", self.host) raise else: - _LOGGER.debug("Failed communicating with Bluesound: %s", - self.host) + _LOGGER.debug("Failed communicating: %s", self.host) return None return data - @asyncio.coroutine - def async_update_status(self): - """Using the poll session to always get the status of the player.""" + async def async_update_status(self): + """Use the poll session to always get the status of the player.""" import xmltodict response = None @@ -315,49 +365,77 @@ def async_update_status(self): etag = self._status.get('@etag', '') if etag != '': - url = 'Status?etag='+etag+'&timeout=60.0' - url = "http://{}:{}/{}".format(self.host, self._port, url) + url = 'Status?etag={}&timeout=120.0'.format(etag) + url = "http://{}:{}/{}".format(self.host, self.port, url) - _LOGGER.debug("calling URL: %s", url) + _LOGGER.debug("Calling URL: %s", url) try: - with async_timeout.timeout(65, loop=self._hass.loop): - response = yield from self._polling_session.get( - url, - headers={'connection': 'keep-alive'}) + with async_timeout.timeout(125, loop=self._hass.loop): + response = await self._polling_session.get( + url, headers={CONNECTION: KEEP_ALIVE}) - if response.status != 200: - _LOGGER.error("Error %s on %s", response.status, url) - - result = yield from response.text() - self._is_online = True - self._last_status_update = dt_util.utcnow() - self._status = xmltodict.parse(result)['status'].copy() - self.schedule_update_ha_state() + 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) + self._group_name = group_name + # the sleep is needed to make sure that the + # devices is synced + 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 + # grouped this isn't a foolproof solution. A better + # solution would be to fetch sync_status more often when + # the device is playing. This would solve alot of + # problems. This change will be done when the + # communication is moved to a separate library + 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.schedule_update_ha_state() - _LOGGER.info("Client connection error, marking %s as offline", - self._name) + self.async_schedule_update_ha_state() + _LOGGER.info( + "Client connection error, marking %s as offline", self._name) raise - @asyncio.coroutine + 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]: + await player.force_update_sync_status() + @Throttle(SYNC_STATUS_INTERVAL) - 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.""" - yield from self._internal_update_sync_status(on_updated_cb, - raise_timeout=False) + await self.force_update_sync_status( + on_updated_cb, raise_timeout=False) - @asyncio.coroutine @Throttle(UPDATE_CAPTURE_INTERVAL) - def async_update_captures(self): - """Update Capture cources.""" - resp = yield from self.send_bluesound_command( + async def async_update_captures(self): + """Update Capture sources.""" + resp = await self.send_bluesound_command( 'RadioBrowse?service=Capture') if not resp: return @@ -381,11 +459,10 @@ def _create_capture_item(item): return self._capture_items - @asyncio.coroutine @Throttle(UPDATE_PRESETS_INTERVAL) - def async_update_presets(self): + async def async_update_presets(self): """Update Presets.""" - resp = yield from self.send_bluesound_command('Presets') + resp = await self.send_bluesound_command('Presets') if not resp: return self._preset_items = [] @@ -398,7 +475,7 @@ def _create_preset_item(item): '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']: @@ -410,11 +487,10 @@ def _create_preset_item(item): return self._preset_items - @asyncio.coroutine @Throttle(UPDATE_SERVICES_INTERVAL) - def async_update_services(self): + async def async_update_services(self): """Update Services.""" - resp = yield from self.send_bluesound_command('Services') + resp = await self.send_bluesound_command('Services') if not resp: return self._services_items = [] @@ -436,13 +512,6 @@ def _create_service_item(item): _create_service_item(resp['services']['service']) return self._services_items -# END Status updates fetchers - -# Media player (and core) properties - @property - def should_poll(self): - """No need to poll information.""" - return True @property def media_content_type(self): @@ -453,20 +522,23 @@ def media_content_type(self): def state(self): """Return the state of the device.""" if self._status is None: - return STATE_OFFLINE + return STATE_OFF + + if self.is_grouped and not self.is_master: + return STATE_GROUPED status = self._status.get('state', None) if status == 'pause' or status == 'stop': return STATE_PAUSED elif status == 'stream' or status == 'play': return STATE_PLAYING - else: - return STATE_IDLE + return STATE_IDLE @property def media_title(self): """Title of current playing media.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None return self._status.get('title1', None) @@ -477,6 +549,9 @@ def media_artist(self): if self._status is None: return None + if self.is_grouped and not self.is_master: + return self._group_name + artist = self._status.get('artist', None) if not artist: artist = self._status.get('title2', None) @@ -485,7 +560,8 @@ def media_artist(self): @property def media_album_name(self): """Artist of current playing media (Music track only).""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None album = self._status.get('album', None) @@ -496,21 +572,23 @@ def media_album_name(self): @property def media_image_url(self): """Image url of current playing media.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None url = self._status.get('image', None) if not url: return if url[0] == '/': - url = "http://{}:{}{}".format(self.host, self._port, url) + url = "http://{}:{}{}".format(self.host, self.port, url) return url @property def media_position(self): """Position of current playing media in seconds.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None mediastate = self.state @@ -531,7 +609,8 @@ def media_position(self): @property def media_duration(self): """Duration of current playing media in seconds.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None duration = self._status.get('totlen', None) @@ -547,10 +626,10 @@ def media_position_updated_at(self): @property def volume_level(self): """Volume level of the media player (0..1).""" - if self._status is None: - return None - volume = self._status.get('volume', None) + if self.is_grouped: + volume = self._sync_status.get('@volume', None) + if volume is not None: return int(volume) / 100 return None @@ -558,9 +637,6 @@ def volume_level(self): @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - if not self._status: - return None - volume = self.volume_level if not volume: return None @@ -579,7 +655,8 @@ def icon(self): @property def source_list(self): """List of available input sources.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None sources = [] @@ -602,7 +679,8 @@ def source(self): """Name of the current input source.""" from urllib import parse - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None current_service = self._status.get('service', '') @@ -611,26 +689,26 @@ def source(self): stream_url = self._status.get('streamUrl', '') if self._status.get('is_preset', '') == '1' and stream_url != '': - # this check doesn't work with all presets, for example playlists. - # But it works with radio service_items will catch playlists + # This check doesn't work with all presets, for example playlists. + # But it works with radio service_items will catch playlists. items = [x for x in self._preset_items if 'url2' in x and parse.unquote(x['url2']) == stream_url] - if len(items) > 0: + if items: return items[0]['title'] - # this could be a bit difficult to detect. Bluetooth could be named + # This could be a bit difficult to detect. Bluetooth could be named # different things and there is not any way to match chooses in # capture list to current playing. It's a bit of guesswork. - # This method will be needing some tweaking over time + # This method will be needing some tweaking over time. title = self._status.get('title1', '').lower() if title == 'bluetooth' or stream_url == 'Capture:hw:2,0/44100/16/2': items = [x for x in self._capture_items if x['url'] == "Capture%3Abluez%3Abluetooth"] - if len(items) > 0: + if items: return items[0]['title'] items = [x for x in self._capture_items if x['url'] == stream_url] - if len(items) > 0: + if items: return items[0]['title'] if stream_url[:8] == 'Capture:': @@ -651,16 +729,16 @@ def source(self): items = [x for x in self._capture_items if x['name'] == current_service] - if len(items) > 0: + if items: return items[0]['title'] items = [x for x in self._services_items if x['name'] == current_service] - if len(items) > 0: + if items: return items[0]['title'] if self._status.get('streamUrl', '') != '': - _LOGGER.debug("Couldn't find source of stream url: %s", + _LOGGER.debug("Couldn't find source of stream URL: %s", self._status.get('streamUrl', '')) return None @@ -670,12 +748,17 @@ def supported_features(self): if self._status is None: return None + if self.is_grouped and not self.is_master: + return SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_SET | \ + SUPPORT_VOLUME_MUTE + supported = SUPPORT_CLEAR_PLAYLIST if self._status.get('indexing', '0') == '0': supported = supported | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | \ - SUPPORT_STOP | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE + SUPPORT_STOP | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE | \ + SUPPORT_SHUFFLE_SET current_vol = self.volume_level if current_vol is not None and current_vol >= 0: @@ -688,19 +771,80 @@ def supported_features(self): return supported @property - def device_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_MODEL: self._model, - ATTR_MODEL_NAME: self._model_name, - ATTR_BRAND: self._brand, - } -# END Media player (and core) properties - -# Media player commands - @asyncio.coroutine - def async_select_source(self, source): + def is_master(self): + """Return true if player is a coordinator.""" + return self._is_master + + @property + def is_grouped(self): + """Return true if player is a coordinator.""" + return self._master is not None or self._is_master + + @property + def shuffle(self): + """Return true if shuffle is active.""" + return True if self._status.get('shuffle', '0') == '1' else False + + 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] + + if master_device: + _LOGGER.debug("Trying to join player: %s to master: %s", + self.host, master_device[0].host) + + await master_device[0].async_add_slave(self) + else: + _LOGGER.error("Master not found %s", master_device) + + 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) + await self._master.async_remove_slave(self) + + async def async_add_slave(self, slave_device): + """Add slave to master.""" + return await self.send_bluesound_command( + '/AddSlave?slave={}&port={}'.format( + slave_device.host, slave_device.port)) + + async def async_remove_slave(self, slave_device): + """Remove slave to master.""" + return await self.send_bluesound_command( + '/RemoveSlave?slave={}&port={}'.format( + slave_device.host, slave_device.port)) + + async def async_increase_timer(self): + """Increase sleep time on player.""" + 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) + return 0 + + return int(sleep_time.get('sleep', '0')) + + async def async_clear_timer(self): + """Clear sleep timer on player.""" + sleep = 1 + while sleep > 0: + sleep = await self.async_increase_timer() + + async def async_set_shuffle(self, shuffle): + """Enable or disable shuffle mode.""" + value = '1' if shuffle else '0' + return await self.send_bluesound_command( + '/Shuffle?state={}'.format(value)) + + async def async_select_source(self, source): """Select input source.""" + if self.is_grouped and not self.is_master: + return + items = [x for x in self._preset_items if x['title'] == source] if len(items) < 1: @@ -712,22 +856,26 @@ def async_select_source(self, source): return selected_source = items[0] - url = 'Play?url={}&preset_id&image={}'.format(selected_source['url'], - selected_source['image']) + url = 'Play?url={}&preset_id&image={}'.format( + selected_source['url'], selected_source['image']) 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.""" - return self.send_bluesound_command('Clear') + if self.is_grouped and not self.is_master: + return - @asyncio.coroutine - def async_media_next_track(self): + return await self.send_bluesound_command('Clear') + + async def async_media_next_track(self): """Send media_next command to media player.""" + if self.is_grouped and not self.is_master: + return + cmd = 'Skip' if self._status and 'actions' in self._status: for action in self._status['actions']['action']: @@ -735,11 +883,13 @@ def async_media_next_track(self): 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 + cmd = 'Back' if self._status and 'actions' in self._status: for action in self._status['actions']['action']: @@ -747,63 +897,83 @@ def async_media_previous_track(self): 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.""" - return self.send_bluesound_command('Play') + if self.is_grouped and not self.is_master: + return + + 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.""" - return self.send_bluesound_command('Pause') + if self.is_grouped and not self.is_master: + return + + return await self.send_bluesound_command('Pause') - @asyncio.coroutine - def async_media_stop(self): + async def async_media_stop(self): """Send stop command.""" - return self.send_bluesound_command('Pause') + if self.is_grouped and not self.is_master: + return + + 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.""" - return self.send_bluesound_command('Play?seek=' + str(float(position))) + if self.is_grouped and not self.is_master: + return + + return await self.send_bluesound_command( + 'Play?seek={}'.format(float(position))) + + async def async_play_media(self, media_type, media_id, **kwargs): + """ + Send the play_media command to the media player. + + If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. + """ + if self.is_grouped and not self.is_master: + return + + url = 'Play?url={}'.format(media_id) + + if kwargs.get(ATTR_MEDIA_ENQUEUE): + return await 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)) -# END Media player commands diff --git a/homeassistant/components/media_player/braviatv.py b/homeassistant/components/media_player/braviatv.py index 399052611c15e..f0cc93a8b0f3f 100644 --- a/homeassistant/components/media_player/braviatv.py +++ b/homeassistant/components/media_player/braviatv.py @@ -5,8 +5,6 @@ https://home-assistant.io/components/media_player.braviatv/ """ import logging -import os -import json import re import voluptuous as vol @@ -18,6 +16,7 @@ PLATFORM_SCHEMA) from homeassistant.const import (CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv +from homeassistant.util.json import load_json, save_json REQUIREMENTS = [ 'https://github.com/aparraga/braviarc/archive/0.3.7.zip' @@ -61,38 +60,6 @@ def _get_mac_address(ip_address): return None -def _config_from_file(filename, config=None): - """Create the configuration from a file.""" - if config: - # We're writing configuration - bravia_config = _config_from_file(filename) - if bravia_config is None: - bravia_config = {} - new_config = bravia_config.copy() - new_config.update(config) - try: - with open(filename, 'w') as fdesc: - fdesc.write(json.dumps(new_config)) - except IOError as error: - _LOGGER.error("Saving config file failed: %s", error) - return False - return True - else: - # We're reading config - if os.path.isfile(filename): - try: - with open(filename, 'r') as fdesc: - return json.loads(fdesc.read()) - except ValueError as error: - return {} - except IOError as error: - _LOGGER.error("Reading config file failed: %s", error) - # This won't work yet - return False - else: - return {} - - # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sony Bravia TV platform.""" @@ -102,7 +69,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return pin = None - bravia_config = _config_from_file(hass.config.path(BRAVIA_CONFIG_FILE)) + bravia_config = load_json(hass.config.path(BRAVIA_CONFIG_FILE)) while bravia_config: # Set up a configured TV host_ip, host_config = bravia_config.popitem() @@ -136,10 +103,9 @@ def setup_bravia(config, pin, hass, add_devices): _LOGGER.info("Discovery configuration done") # Save config - if not _config_from_file( - hass.config.path(BRAVIA_CONFIG_FILE), - {host: {'pin': pin, 'host': host, 'mac': mac}}): - _LOGGER.error("Failed to save configuration file") + save_json( + hass.config.path(BRAVIA_CONFIG_FILE), + {host: {'pin': pin, 'host': host, 'mac': mac}}) add_devices([BraviaTVDevice(host, mac, name, pin)]) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 780bd0e31add1..a9bea9e4c1d13 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -6,21 +6,29 @@ """ # 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 +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) import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pychromecast==0.8.2'] +REQUIREMENTS = ['pychromecast==2.1.0'] _LOGGER = logging.getLogger(__name__) @@ -33,92 +41,424 @@ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY -KNOWN_HOSTS_KEY = 'cast_known_hosts' +# Stores a threading.Lock that is held by the internal pychromecast discovery. +INTERNAL_DISCOVERY_RUNNING_KEY = 'cast_discovery_running' +# 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' + +# 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]) }) -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +@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.""" + 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 + + import pychromecast + + def internal_callback(name): + """Called when zeroconf has discovered a new chromecast.""" + mdns = listener.services[name] + _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() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery) + + +@callback +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. + """ + 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(info) + + # Found a cast with UUID + added_casts = hass.data[ADDED_CAST_DEVICES_KEY] + if info.uuid in added_casts: + # Already added this one, the entity will take care of moved hosts + # itself + return None + # -> New cast device + added_casts.add(info.uuid) + return CastDevice(info) + + +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up the cast platform.""" import pychromecast # Import CEC IGNORE attributes pychromecast.IGNORE_CEC += config.get(CONF_IGNORE_CEC, []) + hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set()) + hass.data.setdefault(KNOWN_CHROMECAST_INFO_KEY, set()) + + info = None + if discovery_info is not None: + info = ChromecastInfo(host=discovery_info['host'], + port=discovery_info['port']) + elif CONF_HOST in config: + info = ChromecastInfo(host=config[CONF_HOST], + port=DEFAULT_PORT) + + @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 + + cast_device = _async_create_cast_device(hass, discover) + 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_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: + 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 + hass.async_add_job(_discover_chromecast, hass, info) - known_hosts = hass.data.get(KNOWN_HOSTS_KEY) - if known_hosts is None: - known_hosts = hass.data[KNOWN_HOSTS_KEY] = [] - if discovery_info: - host = (discovery_info.get('host'), discovery_info.get('port')) +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) - if host in known_hosts: + 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. + + 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] + + 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() - hosts = [host] + await self.hass.async_add_job(self._chromecast.disconnect) - elif CONF_HOST in config: - host = (config.get(CONF_HOST), DEFAULT_PORT) + # 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 - if host in known_hosts: + 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 - hosts = [host] + if self._chromecast.app_id is not None: + # Quit the previous app before starting splash screen + self._chromecast.quit_app() - else: - hosts = [tuple(dev[:2]) for dev in pychromecast.discover_chromecasts() - if tuple(dev[:2]) not in known_hosts] - - casts = [] - - # get_chromecasts() returns Chromecast objects with the correct friendly - # name for grouped devices - all_chromecasts = pychromecast.get_chromecasts() - - for host in hosts: - (_, port) = host - found = [device for device in all_chromecasts - if (device.host, device.port) == host] - if found: - try: - casts.append(CastDevice(found[0])) - known_hosts.append(host) - except pychromecast.ChromecastConnectionError: - pass - - # do not add groups using pychromecast.Chromecast as it leads to names - # collision since pychromecast.Chromecast will get device name instead - # of group name - elif port == DEFAULT_PORT: - try: - # add the device anyway, get_chromecasts couldn't find it - casts.append(CastDevice(pychromecast.Chromecast(*host))) - known_hosts.append(host) - except pychromecast.ChromecastConnectionError: - pass - - add_devices(casts) + # 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() -class CastDevice(MediaPlayerDevice): - """Representation of a Cast device on the network.""" + def mute_volume(self, mute): + """Mute the volume.""" + self._chromecast.set_volume_muted(mute) - def __init__(self, chromecast): - """Initialize the Cast device.""" - self.cast = chromecast + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._chromecast.set_volume(volume) - self.cast.socket_client.receiver_controller.register_status_listener( - self) - self.cast.socket_client.media_controller.register_status_listener(self) + def media_play(self): + """Send play command.""" + self._chromecast.media_controller.play() - self.cast_status = self.cast.status - self.media_status = self.cast.media_controller.status - self.media_status_received = None + 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.""" @@ -127,23 +467,27 @@ def should_poll(self): @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): @@ -168,7 +512,7 @@ def media_content_type(self): 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 @@ -186,7 +530,7 @@ def media_image_url(self): images = self.media_status.images - return images[0].url if images else None + return images[0].url if images and images[0].url else None @property def media_title(self): @@ -205,7 +549,7 @@ def media_album(self): @property def media_album_artist(self): - """Album arist of current playing media (Music track only).""" + """Album artist of current playing media (Music track only).""" return self.media_status.album_artist if self.media_status else None @property @@ -231,12 +575,12 @@ def media_episode(self): @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): @@ -247,11 +591,10 @@ def supported_features(self): 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 @@ -262,66 +605,7 @@ def media_position_updated_at(self): """ 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 commmand.""" - 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) -> Optional[str]: + """Return a unique ID.""" + return self._cast_info.uuid diff --git a/homeassistant/components/media_player/channels.py b/homeassistant/components/media_player/channels.py new file mode 100644 index 0000000000000..6b41ace6ce21b --- /dev/null +++ b/homeassistant/components/media_player/channels.py @@ -0,0 +1,302 @@ +""" +Support for interfacing with an instance of Channels (https://getchannels.com). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.channels/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + MEDIA_TYPE_CHANNEL, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_EPISODE, + 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) + +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING, + ATTR_ENTITY_ID) + +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DATA_CHANNELS = 'channels' +DEFAULT_NAME = 'Channels' +DEFAULT_PORT = 57000 + +FEATURE_SUPPORT = SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | \ + SUPPORT_VOLUME_MUTE | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + +SERVICE_SEEK_FORWARD = 'channels_seek_forward' +SERVICE_SEEK_BACKWARD = 'channels_seek_backward' +SERVICE_SEEK_BY = 'channels_seek_by' + +# Service call validation schemas +ATTR_SECONDS = 'seconds' + +CHANNELS_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_id, +}) + +CHANNELS_SEEK_BY_SCHEMA = CHANNELS_SCHEMA.extend({ + vol.Required(ATTR_SECONDS): vol.Coerce(int), +}) + +REQUIREMENTS = ['pychannels==1.0.0'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Channels platform.""" + device = ChannelsPlayer( + config.get('name'), + config.get(CONF_HOST), + config.get(CONF_PORT) + ) + + if DATA_CHANNELS not in hass.data: + hass.data[DATA_CHANNELS] = [] + + add_devices([device], True) + hass.data[DATA_CHANNELS].append(device) + + def service_handler(service): + """Handler for services.""" + entity_id = service.data.get(ATTR_ENTITY_ID) + + device = next((device for device in hass.data[DATA_CHANNELS] if + device.entity_id == entity_id), None) + + if device is None: + _LOGGER.warning("Unable to find Channels with entity_id: %s", + entity_id) + return + + if service.service == SERVICE_SEEK_FORWARD: + device.seek_forward() + elif service.service == SERVICE_SEEK_BACKWARD: + device.seek_backward() + elif service.service == SERVICE_SEEK_BY: + seconds = service.data.get('seconds') + device.seek_by(seconds) + + hass.services.register( + DOMAIN, SERVICE_SEEK_FORWARD, service_handler, + schema=CHANNELS_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_SEEK_BACKWARD, service_handler, + schema=CHANNELS_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_SEEK_BY, service_handler, + schema=CHANNELS_SEEK_BY_SCHEMA) + + +class ChannelsPlayer(MediaPlayerDevice): + """Representation of a Channels instance.""" + + # pylint: disable=too-many-public-methods + def __init__(self, name, host, port): + """Initialize the Channels app.""" + from pychannels import Channels + + self._name = name + self._host = host + self._port = port + + self.client = Channels(self._host, self._port) + + self.status = None + self.muted = None + + self.channel_number = None + self.channel_name = None + self.channel_image_url = None + + self.now_playing_title = None + self.now_playing_episode_title = None + self.now_playing_season_number = None + self.now_playing_episode_number = None + self.now_playing_summary = None + self.now_playing_image_url = None + + self.favorite_channels = [] + + def update_favorite_channels(self): + """Update the favorite channels from the client.""" + self.favorite_channels = self.client.favorite_channels() + + def update_state(self, state_hash): + """Update all the state properties with the passed in dictionary.""" + self.status = state_hash.get('status', "stopped") + self.muted = state_hash.get('muted', False) + + channel_hash = state_hash.get('channel') + np_hash = state_hash.get('now_playing') + + if channel_hash: + self.channel_number = channel_hash.get('channel_number') + self.channel_name = channel_hash.get('channel_name') + self.channel_image_url = channel_hash.get('channel_image_url') + else: + self.channel_number = None + self.channel_name = None + self.channel_image_url = None + + if np_hash: + self.now_playing_title = np_hash.get('title') + self.now_playing_episode_title = np_hash.get('episode_title') + self.now_playing_season_number = np_hash.get('season_number') + self.now_playing_episode_number = np_hash.get('episode_number') + self.now_playing_summary = np_hash.get('summary') + self.now_playing_image_url = np_hash.get('image_url') + else: + self.now_playing_title = None + self.now_playing_episode_title = None + self.now_playing_season_number = None + self.now_playing_episode_number = None + self.now_playing_summary = None + self.now_playing_image_url = None + + @property + def name(self): + """Return the name of the player.""" + return self._name + + @property + def state(self): + """Return the state of the player.""" + if self.status == 'stopped': + return STATE_IDLE + + if self.status == 'paused': + return STATE_PAUSED + + if self.status == 'playing': + return STATE_PLAYING + + return None + + def update(self): + """Retrieve latest state.""" + self.update_favorite_channels() + self.update_state(self.client.status()) + + @property + def source_list(self): + """List of favorite channels.""" + sources = [channel['name'] for channel in self.favorite_channels] + return sources + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.muted + + @property + def media_content_id(self): + """Content ID of current playing channel.""" + return self.channel_number + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_CHANNEL + + @property + def media_image_url(self): + """Image url of current playing media.""" + if self.now_playing_image_url: + return self.now_playing_image_url + elif self.channel_image_url: + return self.channel_image_url + + return 'https://getchannels.com/assets/img/icon-1024.png' + + @property + def media_title(self): + """Title of current playing media.""" + if self.state: + return self.now_playing_title + + return None + + @property + def supported_features(self): + """Flag of media commands that are supported.""" + return FEATURE_SUPPORT + + def mute_volume(self, mute): + """Mute (true) or unmute (false) player.""" + if mute != self.muted: + response = self.client.toggle_muted() + self.update_state(response) + + def media_stop(self): + """Send media_stop command to player.""" + self.status = "stopped" + response = self.client.stop() + self.update_state(response) + + def media_play(self): + """Send media_play command to player.""" + response = self.client.resume() + self.update_state(response) + + def media_pause(self): + """Send media_pause command to player.""" + response = self.client.pause() + self.update_state(response) + + def media_next_track(self): + """Seek ahead.""" + response = self.client.skip_forward() + self.update_state(response) + + def media_previous_track(self): + """Seek back.""" + response = self.client.skip_backward() + self.update_state(response) + + def select_source(self, source): + """Select a channel to tune to.""" + for channel in self.favorite_channels: + if channel["name"] == source: + response = self.client.play_channel(channel["number"]) + self.update_state(response) + break + + def play_media(self, media_type, media_id, **kwargs): + """Send the play_media command to the player.""" + if media_type == MEDIA_TYPE_CHANNEL: + response = self.client.play_channel(media_id) + self.update_state(response) + elif media_type in [MEDIA_TYPE_MOVIE, MEDIA_TYPE_EPISODE, + MEDIA_TYPE_TVSHOW]: + response = self.client.play_recording(media_id) + self.update_state(response) + + def seek_forward(self): + """Seek forward in the timeline.""" + response = self.client.seek_forward() + self.update_state(response) + + def seek_backward(self): + """Seek backward in the timeline.""" + response = self.client.seek_backward() + self.update_state(response) + + def seek_by(self, seconds): + """Seek backward in the timeline.""" + response = self.client.seek(seconds) + self.update_state(response) diff --git a/homeassistant/components/media_player/clementine.py b/homeassistant/components/media_player/clementine.py index d9688badcd19f..6847b87e54f7c 100644 --- a/homeassistant/components/media_player/clementine.py +++ b/homeassistant/components/media_player/clementine.py @@ -37,7 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_ACCESS_TOKEN, default=None): cv.positive_int, + vol.Optional(CONF_ACCESS_TOKEN): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }) @@ -97,7 +97,7 @@ def update(self): self._track_artist = client.current_track['track_artist'] self._track_album_name = client.current_track['track_album'] - except: + except Exception: self._state = STATE_OFF raise diff --git a/homeassistant/components/media_player/cmus.py b/homeassistant/components/media_player/cmus.py index fe25422360c5f..0758b5f3058e8 100644 --- a/homeassistant/components/media_player/cmus.py +++ b/homeassistant/components/media_player/cmus.py @@ -19,7 +19,7 @@ CONF_PASSWORD) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pycmus==0.1.0'] +REQUIREMENTS = ['pycmus==0.1.1'] _LOGGER = logging.getLogger(__name__) @@ -77,7 +77,7 @@ def update(self): """Get the latest data and update the state.""" status = self.cmus.get_status_dict() if not status: - _LOGGER.warning("Recieved no status from cmus") + _LOGGER.warning("Received no status from cmus") else: self.status = status diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index 9e4e912f31408..22fe1d005f711 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -5,7 +5,7 @@ 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, @@ -37,11 +37,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): MUSIC_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST | \ - SUPPORT_PLAY | SUPPORT_SHUFFLE_SET + SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK NETFLIX_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK class AbstractDemoPlayer(MediaPlayerDevice): @@ -145,7 +147,7 @@ def media_content_id(self): @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): @@ -284,15 +286,7 @@ def media_track(self): @property def supported_features(self): """Flag media player features that are supported.""" - support = MUSIC_PLAYER_SUPPORT - - if self._cur_track > 0: - support |= SUPPORT_PREVIOUS_TRACK - - if self._cur_track < len(self.tracks) - 1: - support |= SUPPORT_NEXT_TRACK - - return support + return MUSIC_PLAYER_SUPPORT def media_previous_track(self): """Send previous track command.""" @@ -379,15 +373,7 @@ def source(self): @property def supported_features(self): """Flag media player features that are supported.""" - support = NETFLIX_PLAYER_SUPPORT - - if self._cur_episode > 1: - support |= SUPPORT_PREVIOUS_TRACK - - if self._cur_episode < self._episode_count: - support |= SUPPORT_NEXT_TRACK - - return support + return NETFLIX_PLAYER_SUPPORT def media_previous_track(self): """Send previous track command.""" diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py old mode 100755 new mode 100644 index 68fb629e5ea31..d85bd51e7fb86 --- a/homeassistant/components/media_player/denon.py +++ b/homeassistant/components/media_player/denon.py @@ -78,7 +78,9 @@ def __init__(self, name, host): def _setup_sources(self, telnet): # NSFRN - Network name - self._name = self.telnet_request(telnet, 'NSFRN ?')[len('NSFRN '):] + nsfrn = self.telnet_request(telnet, 'NSFRN ?')[len('NSFRN '):] + if nsfrn: + self._name = nsfrn # SSFUN - Configured sources with names self._source_list = {} @@ -106,11 +108,11 @@ def telnet_request(cls, telnet, command, all_lines=False): if not line: break lines.append(line.decode('ASCII').strip()) - _LOGGER.debug("Recived: %s", line) + _LOGGER.debug("Received: %s", line) if all_lines: return lines - return lines[0] + return lines[0] if lines else '' def telnet_command(self, command): """Establish a telnet connection and sends `command`.""" @@ -225,7 +227,7 @@ def mute_volume(self, mute): self.telnet_command('MU' + ('ON' if mute else 'OFF')) def media_play(self): - """Play media media player.""" + """Play media player.""" self.telnet_command('NS9A') def media_pause(self): diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 9433951471286..fe8fc46c24b26 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -20,7 +20,7 @@ CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.5.3'] +REQUIREMENTS = ['denonavr==0.6.1'] _LOGGER = logging.getLogger(__name__) @@ -43,12 +43,12 @@ DENON_ZONE_SCHEMA = vol.Schema({ vol.Required(CONF_ZONE): vol.In(CONF_VALID_ZONES, CONF_INVALID_ZONES_ERR), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_SHOW_ALL_SOURCES, default=DEFAULT_SHOW_SOURCES): cv.boolean, vol.Optional(CONF_ZONES): @@ -80,7 +80,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if zones is not None: add_zones = {} for entry in zones: - add_zones[entry[CONF_ZONE]] = entry[CONF_NAME] + add_zones[entry[CONF_ZONE]] = entry.get(CONF_NAME) else: add_zones = None @@ -102,12 +102,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if config.get(CONF_HOST) is None and discovery_info is None: d_receivers = denonavr.discover() # More than one receiver could be discovered by that method - if d_receivers is not None: - for d_receiver in d_receivers: - host = d_receiver["host"] - name = d_receiver["friendlyName"] - new_hosts.append( - NewHost(host=host, name=name)) + for d_receiver in d_receivers: + host = d_receiver["host"] + name = d_receiver["friendlyName"] + new_hosts.append( + NewHost(host=host, name=name)) for entry in new_hosts: # Check if host not in cache, append it and save for later diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index a334dc7caa4c7..25d13e3017a16 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -1,5 +1,5 @@ """ -Support for the DirecTV recievers. +Support for the DirecTV receivers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.directv/ @@ -8,7 +8,7 @@ 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 @@ CONF_DEVICE, CONF_HOST, CONF_NAME, STATE_OFF, STATE_PLAYING, CONF_PORT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['directpy==0.1'] +REQUIREMENTS = ['directpy==0.2'] DEFAULT_DEVICE = '0' DEFAULT_NAME = 'DirecTV Receiver' @@ -82,7 +82,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class DirecTvDevice(MediaPlayerDevice): - """Representation of a DirecTV reciever on the network.""" + """Representation of a DirecTV receiver on the network.""" def __init__(self, name, host, port, device): """Initialize the device.""" @@ -154,7 +154,7 @@ def media_content_type(self): """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/dunehd.py b/homeassistant/components/media_player/dunehd.py index 76c15e9782432..efa5e7e607983 100644 --- a/homeassistant/components/media_player/dunehd.py +++ b/homeassistant/components/media_player/dunehd.py @@ -124,7 +124,7 @@ def turn_on(self): self.schedule_update_ha_state() def media_play(self): - """Play media media player.""" + """Play media player.""" self._state = self._player.play() self.schedule_update_ha_state() diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py index 8df6bc4fd1b92..4f9a401926824 100644 --- a/homeassistant/components/media_player/emby.py +++ b/homeassistant/components/media_player/emby.py @@ -10,7 +10,7 @@ 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 ( @@ -21,7 +21,7 @@ import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pyemby==1.4'] +REQUIREMENTS = ['pyemby==1.5'] _LOGGER = logging.getLogger(__name__) @@ -45,7 +45,7 @@ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_PORT, default=None): cv.port, + vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_AUTO_HIDE, default=DEFAULT_AUTO_HIDE): cv.boolean, }) @@ -159,7 +159,7 @@ def async_update_callback(self, msg): self.media_status_last_position = None self.media_status_received = None - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def hidden(self): @@ -182,7 +182,7 @@ def set_available(self, value): @property def unique_id(self): """Return the id of this emby client.""" - return '{}.{}'.format(self.__class__, self.device_id) + return self.device_id @property def supports_remote_control(self): @@ -231,7 +231,7 @@ def media_content_type(self): 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': @@ -273,7 +273,7 @@ def media_title(self): @property def media_season(self): - """Season of curent playing media (TV Show only).""" + """Season of current playing media (TV Show only).""" return self.device.media_season @property diff --git a/homeassistant/components/media_player/frontier_silicon.py b/homeassistant/components/media_player/frontier_silicon.py index f46d065760470..6d95ea675fb81 100644 --- a/homeassistant/components/media_player/frontier_silicon.py +++ b/homeassistant/components/media_player/frontier_silicon.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.frontier_silicon/ """ +import asyncio import logging import voluptuous as vol @@ -19,7 +20,7 @@ CONF_HOST, CONF_PORT, CONF_PASSWORD) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['fsapi==0.0.7'] +REQUIREMENTS = ['afsapi==0.0.3'] _LOGGER = logging.getLogger(__name__) @@ -41,14 +42,15 @@ # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Frontier Silicon platform.""" import requests if discovery_info is not None: - add_devices( - [FSAPIDevice(discovery_info['ssdp_description'], - DEFAULT_PASSWORD)], + async_add_devices( + [AFSAPIDevice(discovery_info['ssdp_description'], + DEFAULT_PASSWORD)], update_before_add=True) return True @@ -57,8 +59,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): password = config.get(CONF_PASSWORD) try: - add_devices( - [FSAPIDevice(DEVICE_URL.format(host, port), password)], + async_add_devices( + [AFSAPIDevice(DEVICE_URL.format(host, port), password)], update_before_add=True) _LOGGER.debug("FSAPI device %s:%s -> %s", host, port, password) return True @@ -69,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return False -class FSAPIDevice(MediaPlayerDevice): +class AFSAPIDevice(MediaPlayerDevice): """Representation of a Frontier Silicon device on the network.""" def __init__(self, device_url, password): @@ -97,9 +99,9 @@ def fs_device(self): connected to the device in between the updates and invalidated the existing session (i.e UNDOK). """ - from fsapi import FSAPI + from afsapi import AFSAPI - return FSAPI(self._device_url, self._password) + return AFSAPI(self._device_url, self._password) @property def should_poll(self): @@ -157,17 +159,18 @@ def media_image_url(self): """Image url of current playing media.""" return self._media_image_url - def update(self): + @asyncio.coroutine + def async_update(self): """Get the latest date and update device state.""" fs_device = self.fs_device if not self._name: - self._name = fs_device.friendly_name + self._name = yield from fs_device.get_friendly_name() if not self._source_list: - self._source_list = fs_device.mode_list + self._source_list = yield from fs_device.get_mode_list() - status = fs_device.play_status + status = yield from fs_device.get_play_status() self._state = { 'playing': STATE_PLAYING, 'paused': STATE_PAUSED, @@ -176,54 +179,70 @@ def update(self): None: STATE_OFF, }.get(status, STATE_UNKNOWN) - info_name = fs_device.play_info_name - info_text = fs_device.play_info_text + if self._state != STATE_OFF: + info_name = yield from fs_device.get_play_name() + info_text = yield from fs_device.get_play_text() - self._title = ' - '.join(filter(None, [info_name, info_text])) - self._artist = fs_device.play_info_artist - self._album_name = fs_device.play_info_album + self._title = ' - '.join(filter(None, [info_name, info_text])) + self._artist = yield from fs_device.get_play_artist() + self._album_name = yield from fs_device.get_play_album() - self._source = fs_device.mode - self._mute = fs_device.mute - self._media_image_url = fs_device.play_info_graphics + self._source = yield from fs_device.get_mode() + self._mute = yield from fs_device.get_mute() + self._media_image_url = yield from fs_device.get_play_graphic() + else: + self._title = None + self._artist = None + self._album_name = None - # Management actions + self._source = None + self._mute = None + self._media_image_url = None + # Management actions # power control - def turn_on(self): + @asyncio.coroutine + def async_turn_on(self): """Turn on the device.""" - self.fs_device.power = True + yield from self.fs_device.set_power(True) - def turn_off(self): + @asyncio.coroutine + def async_turn_off(self): """Turn off the device.""" - self.fs_device.power = False + yield from self.fs_device.set_power(False) - def media_play(self): + @asyncio.coroutine + def async_media_play(self): """Send play command.""" - self.fs_device.play() + yield from self.fs_device.play() - def media_pause(self): + @asyncio.coroutine + def async_media_pause(self): """Send pause command.""" - self.fs_device.pause() + yield from self.fs_device.pause() - def media_play_pause(self): + @asyncio.coroutine + def async_media_play_pause(self): """Send play/pause command.""" if 'playing' in self._state: - self.fs_device.pause() + yield from self.fs_device.pause() else: - self.fs_device.play() + yield from self.fs_device.play() - def media_stop(self): + @asyncio.coroutine + def async_media_stop(self): """Send play/pause command.""" - self.fs_device.pause() + yield from self.fs_device.pause() - def media_previous_track(self): + @asyncio.coroutine + def async_media_previous_track(self): """Send previous track command (results in rewind).""" - self.fs_device.prev() + yield from self.fs_device.rewind() - def media_next_track(self): + @asyncio.coroutine + def async_media_next_track(self): """Send next track command (results in fast-forward).""" - self.fs_device.next() + yield from self.fs_device.forward() # mute @property @@ -231,23 +250,30 @@ def is_volume_muted(self): """Boolean if volume is currently muted.""" return self._mute - def mute_volume(self, mute): + @asyncio.coroutine + def async_mute_volume(self, mute): """Send mute command.""" - self.fs_device.mute = mute + yield from self.fs_device.set_mute(mute) # volume - def volume_up(self): + @asyncio.coroutine + def async_volume_up(self): """Send volume up command.""" - self.fs_device.volume += 1 + volume = yield from self.fs_device.get_volume() + yield from self.fs_device.set_volume(volume+1) - def volume_down(self): + @asyncio.coroutine + def async_volume_down(self): """Send volume down command.""" - self.fs_device.volume -= 1 + volume = yield from self.fs_device.get_volume() + yield from self.fs_device.set_volume(volume-1) - def set_volume_level(self, volume): + @asyncio.coroutine + def async_set_volume_level(self, volume): """Set volume command.""" - self.fs_device.volume = volume + yield from self.fs_device.set_volume(volume) - def select_source(self, source): + @asyncio.coroutine + def async_select_source(self, source): """Select input source.""" - self.fs_device.mode = source + yield from self.fs_device.set_mode(source) diff --git a/homeassistant/components/media_player/gpmdp.py b/homeassistant/components/media_player/gpmdp.py index 4090f4208552a..2f116abebc331 100644 --- a/homeassistant/components/media_player/gpmdp.py +++ b/homeassistant/components/media_player/gpmdp.py @@ -6,7 +6,6 @@ """ import logging import json -import os import socket import time @@ -19,6 +18,7 @@ from homeassistant.const import ( STATE_PLAYING, STATE_PAUSED, STATE_OFF, CONF_HOST, CONF_PORT, CONF_NAME) import homeassistant.helpers.config_validation as cv +from homeassistant.util.json import load_json, save_json REQUIREMENTS = ['websocket-client==0.37.0'] @@ -86,8 +86,7 @@ def gpmdp_configuration_callback(callback_data): continue setup_gpmdp(hass, config, code, add_devices_callback) - _save_config(hass.config.path(GPMDP_CONFIG_FILE), - {"CODE": code}) + save_json(hass.config.path(GPMDP_CONFIG_FILE), {"CODE": code}) websocket.send(json.dumps({'namespace': 'connect', 'method': 'connect', 'arguments': ['Home Assistant', code]})) @@ -122,39 +121,9 @@ def setup_gpmdp(hass, config, code, add_devices): add_devices([GPMDP(name, url, code)], True) -def _load_config(filename): - """Load configuration.""" - if not os.path.isfile(filename): - return {} - - try: - with open(filename, 'r') as fdesc: - inp = fdesc.read() - - # In case empty file - if not inp: - return {} - - return json.loads(inp) - except (IOError, ValueError) as error: - _LOGGER.error("Reading config file %s failed: %s", filename, error) - return None - - -def _save_config(filename, config): - """Save configuration.""" - try: - with open(filename, 'w') as fdesc: - fdesc.write(json.dumps(config, indent=4, sort_keys=True)) - except (IOError, TypeError) as error: - _LOGGER.error("Saving configuration file failed: %s", error) - return False - return True - - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the GPMDP platform.""" - codeconfig = _load_config(hass.config.path(GPMDP_CONFIG_FILE)) + codeconfig = load_json(hass.config.path(GPMDP_CONFIG_FILE)) if codeconfig: code = codeconfig.get('CODE') elif discovery_info is not None: diff --git a/homeassistant/components/media_player/hdmi_cec.py b/homeassistant/components/media_player/hdmi_cec.py index 7054c83d36a47..f5b4cbd48546b 100644 --- a/homeassistant/components/media_player/hdmi_cec.py +++ b/homeassistant/components/media_player/hdmi_cec.py @@ -32,9 +32,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class CecPlayerDevice(CecDevice, MediaPlayerDevice): - """Representation of a HDMI device as a Media palyer.""" + """Representation of a HDMI device as a Media player.""" - def __init__(self, hass: HomeAssistant, device, logical): + def __init__(self, hass: HomeAssistant, device, logical) -> None: """Initialize the HDMI device.""" CecDevice.__init__(self, hass, device, logical) self.entity_id = "%s.%s_%s" % ( @@ -87,7 +87,7 @@ def media_stop(self): self.send_keypress(KEY_STOP) self._state = STATE_IDLE - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Not supported.""" raise NotImplementedError() diff --git a/homeassistant/components/media_player/itunes.py b/homeassistant/components/media_player/itunes.py index 575ea414fa328..ca0979f1752d8 100644 --- a/homeassistant/components/media_player/itunes.py +++ b/homeassistant/components/media_player/itunes.py @@ -29,7 +29,7 @@ SUPPORT_ITUNES = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \ - SUPPORT_PLAY_MEDIA | SUPPORT_PLAY + SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_TURN_OFF SUPPORT_AIRPLAY = SUPPORT_VOLUME_SET | SUPPORT_TURN_ON | SUPPORT_TURN_OFF @@ -115,6 +115,10 @@ def previous(self): """Skip back and returns the current state.""" return self._command('previous') + def stop(self): + """Stop playback and return the current state.""" + return self._command('stop') + def play_playlist(self, playlist_id_or_name): """Set a playlist to be current and returns the current state.""" response = self._request('GET', '/playlists') @@ -280,7 +284,7 @@ def media_image_url(self): """Image url of current playing media.""" if self.player_state in (STATE_PLAYING, STATE_IDLE, STATE_PAUSED) and \ self.current_title is not None: - return self.client.artwork_url() + return self.client.artwork_url() + '?id=' + self.content_id return 'https://cloud.githubusercontent.com/assets/260/9829355' \ '/33fab972-58cf-11e5-8ea2-2ca74bdaae40.png' @@ -346,6 +350,11 @@ def play_media(self, media_type, media_id, **kwargs): response = self.client.play_playlist(media_id) self.update_state(response) + def turn_off(self): + """Turn the media player off.""" + response = self.client.stop() + self.update_state(response) + class AirPlayDevice(MediaPlayerDevice): """Representation an AirPlay device via an iTunes API instance.""" diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index a51238e9aaf63..770d57b5b8e49 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -8,21 +8,20 @@ from collections import OrderedDict from functools import wraps import logging +import socket import urllib import re -import os import aiohttp import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, 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_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, @@ -33,7 +32,7 @@ from homeassistant.helpers.template import Template from homeassistant.util.yaml import dump -REQUIREMENTS = ['jsonrpc-async==0.6', 'jsonrpc-websocket==0.5'] +REQUIREMENTS = ['jsonrpc-async==0.6', 'jsonrpc-websocket==0.6'] _LOGGER = logging.getLogger(__name__) @@ -69,10 +68,14 @@ '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 | \ @@ -86,7 +89,7 @@ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, vol.Optional(CONF_PROXY_SSL, default=DEFAULT_PROXY_SSL): cv.boolean, - vol.Optional(CONF_TURN_ON_ACTION, default=None): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TURN_OFF_ACTION): vol.Any(cv.SCRIPT_SCHEMA, vol.In(DEPRECATED_TURN_OFF_ACTIONS)), vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, @@ -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_service_handler(service): 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: @@ -207,15 +227,11 @@ def async_service_handler(service): if hass.services.has_service(DOMAIN, SERVICE_ADD_MEDIA): return - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - for service in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service]['schema'] hass.services.async_register( DOMAIN, service, async_service_handler, - description=descriptions.get(service), schema=schema) + schema=schema) def cmd(func): @@ -325,7 +341,7 @@ def async_on_speed_event(self, sender, data): # If a new item is playing, force a complete refresh force_refresh = data['item']['id'] != self._item.get('id') - self.hass.async_add_job(self.async_update_ha_state(force_refresh)) + self.async_schedule_update_ha_state(force_refresh) @callback def async_on_stop(self, sender, data): @@ -337,14 +353,14 @@ def async_on_stop(self, sender, data): self._players = [] self._properties = {} self._item = {} - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def async_on_volume_changed(self, sender, data): """Handle the volume changes.""" self._app_properties['volume'] = data['volume'] self._app_properties['muted'] = data['muted'] - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def async_on_quit(self, sender, data): @@ -403,7 +419,7 @@ def ws_loop_wrapper(): # to reconnect on the next poll. pass # Update HA state after Kodi disconnects - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Create a task instead of adding a tracking job, since this task will # run until the websocket connection is closed. @@ -484,7 +500,12 @@ def media_content_id(self): @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 diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py index e657e1ce80d20..edbd6546cca9f 100644 --- a/homeassistant/components/media_player/lg_netcast.py +++ b/homeassistant/components/media_player/lg_netcast.py @@ -38,7 +38,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_ACCESS_TOKEN, default=None): + vol.Optional(CONF_ACCESS_TOKEN): vol.All(cv.string, vol.Length(max=6)), }) diff --git a/homeassistant/components/media_player/liveboxplaytv.py b/homeassistant/components/media_player/liveboxplaytv.py index 43678d9082993..4fe4da5a94270 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -4,13 +4,13 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.liveboxplaytv/ """ +import asyncio import logging from datetime import timedelta import requests import voluptuous as vol -import homeassistant.util as util from homeassistant.components.media_player import ( SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PLAY, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, @@ -18,10 +18,11 @@ MEDIA_TYPE_CHANNEL, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, CONF_PORT, STATE_ON, STATE_OFF, STATE_PLAYING, - STATE_PAUSED, STATE_UNKNOWN, CONF_NAME) + STATE_PAUSED, CONF_NAME) import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util -REQUIREMENTS = ['liveboxplaytv==1.4.9'] +REQUIREMENTS = ['liveboxplaytv==2.0.2', 'pyteleloisirs==3.4'] _LOGGER = logging.getLogger(__name__) @@ -43,8 +44,8 @@ }) -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Orange Livebox Play TV platform.""" host = config.get(CONF_HOST) port = config.get(CONF_PORT) @@ -58,7 +59,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): except IOError: _LOGGER.error("Failed to connect to Livebox Play TV at %s:%s. " "Please check your configuration", host, port) - add_devices(livebox_devices, True) + async_add_devices(livebox_devices, True) class LiveboxPlayTvDevice(MediaPlayerDevice): @@ -72,27 +73,49 @@ def __init__(self, host, port, name): self._muted = False self._name = name self._current_source = None - self._state = STATE_UNKNOWN + self._state = None self._channel_list = {} self._current_channel = None self._current_program = None + self._media_duration = None + self._media_remaining_time = None self._media_image_url = None + self._media_last_updated = None - @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - def update(self): + @asyncio.coroutine + def async_update(self): """Retrieve the latest data.""" + import pyteleloisirs try: self._state = self.refresh_state() # Update current channel - channel = self._client.get_current_channel() + channel = self._client.channel if channel is not None: - self._current_program = self._client.program - self._current_channel = channel.get('name', None) - self._media_image_url = \ - self._client.get_current_channel_image(img_size=300) - self.refresh_channel_list() + self._current_channel = channel + program = yield from \ + self._client.async_get_current_program() + if program and self._current_program != program.get('name'): + self._current_program = program.get('name') + # Media progress info + self._media_duration = \ + pyteleloisirs.get_program_duration(program) + rtime = pyteleloisirs.get_remaining_time(program) + if rtime != self._media_remaining_time: + self._media_remaining_time = rtime + self._media_last_updated = dt_util.utcnow() + # Set media image to current program if a thumbnail is + # available. Otherwise we'll use the channel's image. + img_size = 800 + prg_img_url = yield from \ + self._client.async_get_current_program_image(img_size) + if prg_img_url: + self._media_image_url = prg_img_url + else: + chan_img_url = \ + self._client.get_current_channel_image(img_size) + self._media_image_url = chan_img_url except requests.ConnectionError: - self._state = STATE_OFF + self._state = None @property def name(self): @@ -136,8 +159,28 @@ def media_image_url(self): def media_title(self): """Title of current playing media.""" if self._current_channel: - return '{}: {}'.format(self._current_channel, - self._current_program) + if self._current_program: + return '{}: {}'.format(self._current_channel, + self._current_program) + return self._current_channel + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._media_duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._media_remaining_time + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self._media_last_updated @property def supported_features(self): diff --git a/homeassistant/components/media_player/mediaroom.py b/homeassistant/components/media_player/mediaroom.py new file mode 100644 index 0000000000000..f5b7567aa348c --- /dev/null +++ b/homeassistant/components/media_player/mediaroom.py @@ -0,0 +1,329 @@ +""" +Support for the Mediaroom Set-up-box. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.mediaroom/ +""" +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, +) +from homeassistant.helpers.dispatcher import ( + dispatcher_send, async_dispatcher_connect +) +from homeassistant.const import ( + 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.6.3'] + +_LOGGER = logging.getLogger(__name__) + +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 + +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, + } +) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Mediaroom platform.""" + 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: + async_add_devices([MediaroomDevice(host=host, + device_id=None, + optimistic=config[CONF_OPTIMISTIC], + timeout=config[CONF_TIMEOUT])]) + hass.data[DATA_MEDIAROOM].append(host) + + _LOGGER.debug("Trying to discover Mediaroom 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 + + _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]) + + if not config[CONF_OPTIMISTIC]: + from pymediaroom import install_mediaroom_protocol + + already_installed = hass.data.get(DISCOVERY_MEDIAROOM, None) + if not already_installed: + hass.data[DISCOVERY_MEDIAROOM] = await install_mediaroom_protocol( + responses_callback=callback_notify) + + @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 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.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_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 + + @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.""" + 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() + + async_dispatcher_connect(self.hass, SIGNAL_STB_NOTIFY, + async_notify_received) + + async def async_play_media(self, media_type, media_id, **kwargs): + """Play media.""" + 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 not media_id.isdigit(): + _LOGGER.error("media_id must be a channel number") + return + + 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 + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_MEDIAROOM + + @property + def media_content_type(self): + """Return the content type of current playing media.""" + return MEDIA_TYPE_CHANNEL + + @property + def media_channel(self): + """Channel currently playing.""" + return self._channel + + async def async_turn_on(self): + """Turn on the receiver.""" + 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() + + async def async_turn_off(self): + """Turn off the receiver.""" + 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() + + async def async_media_play(self): + """Send play command.""" + 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() + + async def async_media_pause(self): + """Send pause command.""" + 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() + + async def async_media_stop(self): + """Send stop command.""" + 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() + + async def async_media_previous_track(self): + """Send Program Down command.""" + 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() + + async def async_media_next_track(self): + """Send Program Up command.""" + 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() + + async def async_volume_up(self): + """Send volume up command.""" + 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() + + async def async_volume_down(self): + """Send volume up command.""" + from pymediaroom import PyMediaroomError + try: + await self.stb.send_cmd('VolDown') + except PyMediaroomError: + self._available = False + self.async_schedule_update_ha_state() + + async def async_mute_volume(self, mute): + """Send mute command.""" + 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/monoprice.py b/homeassistant/components/media_player/monoprice.py new file mode 100644 index 0000000000000..44d19ac686039 --- /dev/null +++ b/homeassistant/components/media_player/monoprice.py @@ -0,0 +1,227 @@ +""" +Support for interfacing with Monoprice 6 zone home audio controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.monoprice/ +""" +import logging + +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, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, MediaPlayerDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pymonoprice==0.3'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_MONOPRICE = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \ + SUPPORT_VOLUME_STEP | 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' + +DATA_MONOPRICE = 'monoprice' + +SERVICE_SNAPSHOT = 'snapshot' +SERVICE_RESTORE = 'restore' + +# Valid zone ids: 11-16 or 21-26 or 31-36 +ZONE_IDS = vol.All(vol.Coerce(int), vol.Any( + vol.Range(min=11, max=16), vol.Range(min=21, max=26), + vol.Range(min=31, max=36))) + +# Valid source ids: 1-6 +SOURCE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=6)) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PORT): 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 6-zone amplifier platform.""" + port = config.get(CONF_PORT) + + from serial import SerialException + from pymonoprice import get_monoprice + try: + monoprice = get_monoprice(port) + except SerialException: + _LOGGER.error("Error connecting to Monoprice controller") + return + + sources = {source_id: extra[CONF_NAME] for source_id, extra + in config[CONF_SOURCES].items()} + + hass.data[DATA_MONOPRICE] = [] + for zone_id, extra in config[CONF_ZONES].items(): + _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME]) + hass.data[DATA_MONOPRICE].append(MonopriceZone( + monoprice, sources, zone_id, extra[CONF_NAME])) + + add_devices(hass.data[DATA_MONOPRICE], True) + + def service_handle(service): + """Handle for services.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + + if entity_ids: + devices = [device for device in hass.data[DATA_MONOPRICE] + if device.entity_id in entity_ids] + else: + devices = hass.data[DATA_MONOPRICE] + + for device in devices: + if service.service == SERVICE_SNAPSHOT: + device.snapshot() + elif service.service == SERVICE_RESTORE: + device.restore() + + hass.services.register( + DOMAIN, SERVICE_SNAPSHOT, service_handle, schema=MEDIA_PLAYER_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_RESTORE, service_handle, schema=MEDIA_PLAYER_SCHEMA) + + +class MonopriceZone(MediaPlayerDevice): + """Representation of a Monoprice amplifier zone.""" + + def __init__(self, monoprice, sources, zone_id, zone_name): + """Initialize new zone.""" + self._monoprice = monoprice + # 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._snapshot = None + self._state = None + self._volume = None + self._source = None + self._mute = None + + def update(self): + """Retrieve latest state.""" + state = self._monoprice.zone_status(self._zone_id) + if not state: + return False + self._state = STATE_ON if state.power else STATE_OFF + self._volume = state.volume + self._mute = state.mute + idx = state.source + if idx in self._source_id_name: + self._source = self._source_id_name[idx] + else: + self._source = None + return True + + @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 volume_level(self): + """Volume level of the media player (0..1).""" + if self._volume is None: + return None + return self._volume / 38.0 + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._mute + + @property + def supported_features(self): + """Return flag of media commands that are supported.""" + return SUPPORT_MONOPRICE + + @property + def media_title(self): + """Return the current source as medial 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 snapshot(self): + """Save zone's current state.""" + self._snapshot = self._monoprice.zone_status(self._zone_id) + + def restore(self): + """Restore saved state.""" + if self._snapshot: + self._monoprice.restore_zone(self._snapshot) + self.schedule_update_ha_state(True) + + def select_source(self, source): + """Set input source.""" + if source not in self._source_name_id: + return + idx = self._source_name_id[source] + self._monoprice.set_source(self._zone_id, idx) + + def turn_on(self): + """Turn the media player on.""" + self._monoprice.set_power(self._zone_id, True) + + def turn_off(self): + """Turn the media player off.""" + self._monoprice.set_power(self._zone_id, False) + + def mute_volume(self, mute): + """Mute (true) or unmute (false) media player.""" + self._monoprice.set_mute(self._zone_id, mute) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._monoprice.set_volume(self._zone_id, int(volume * 38)) + + def volume_up(self): + """Volume up the media player.""" + if self._volume is None: + return + self._monoprice.set_volume(self._zone_id, min(self._volume + 1, 38)) + + def volume_down(self): + """Volume down media player.""" + if self._volume is None: + return + self._monoprice.set_volume(self._zone_id, max(self._volume - 1, 0)) diff --git a/homeassistant/components/media_player/mpchc.py b/homeassistant/components/media_player/mpchc.py index cc195db259002..a375a585ad40d 100644 --- a/homeassistant/components/media_player/mpchc.py +++ b/homeassistant/components/media_player/mpchc.py @@ -155,8 +155,8 @@ def media_stop(self): 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 55df1e367a47b..04dd1ac5f2e1e 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -4,24 +4,26 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.mpd/ """ -import logging from datetime import timedelta +import logging +import os import voluptuous as vol from homeassistant.components.media_player import ( - MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, - SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, - SUPPORT_VOLUME_SET, SUPPORT_PLAY_MEDIA, SUPPORT_PLAY, MEDIA_TYPE_PLAYLIST, - SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, SUPPORT_SHUFFLE_SET, - SUPPORT_SEEK, MediaPlayerDevice) + MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, PLATFORM_SCHEMA, + SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( - STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING, - CONF_PORT, CONF_PASSWORD, CONF_HOST, CONF_NAME) + CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, STATE_OFF, STATE_PAUSED, + STATE_PLAYING) 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__) @@ -30,11 +32,11 @@ PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=120) -SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ +SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_MUTE | \ SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE | \ SUPPORT_CLEAR_PLAYLIST | SUPPORT_SHUFFLE_SET | SUPPORT_SEEK | \ - SUPPORT_STOP + SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -74,6 +76,8 @@ def __init__(self, server, port, password, name): self._playlists = [] self._currentplaylist = None self._is_connected = False + self._muted = False + self._muted_volume = 0 # set up MPD client self._client = mpd.MPDClient() @@ -112,7 +116,7 @@ def _fetch_status(self): @property def available(self): - """True if MPD is available and connected.""" + """Return true if MPD is available and connected.""" return self._is_connected def update(self): @@ -142,8 +146,15 @@ def state(self): return STATE_PLAYING elif self._status['state'] == 'pause': return STATE_PAUSED + elif self._status['state'] == 'stop': + return STATE_OFF - return STATE_ON + return STATE_OFF + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted @property def media_content_id(self): @@ -166,9 +177,12 @@ def media_title(self): """Return the title of current playing media.""" name = self._currentsong.get('name', None) title = self._currentsong.get('title', None) + file_name = self._currentsong.get('file', None) if name is None and title is None: - return "None" + if file_name is None: + return "None" + return os.path.basename(file_name) elif name is None: return title elif title is None: @@ -255,6 +269,15 @@ def media_previous_track(self): """Service to send the MPD the command for previous track.""" self._client.previous() + def mute_volume(self, mute): + """Mute. Emulated with set_volume_level.""" + if mute is True: + self._muted_volume = self.volume_level + self.set_volume_level(0) + elif mute is False: + self.set_volume_level(self._muted_volume) + self._muted = mute + def play_media(self, media_type, media_id, **kwargs): """Send the media player the command for playing a playlist.""" _LOGGER.debug(str.format("Playing playlist: {0}", media_id)) @@ -282,6 +305,15 @@ def set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" self._client.random(int(shuffle)) + def turn_off(self): + """Service to send the MPD the command to stop playing.""" + self._client.stop() + + def turn_on(self): + """Service to send the MPD the command to start playing.""" + self._client.play() + self._update_playlists(no_throttle=True) + def clear_playlist(self): """Clear players playlist.""" self._client.clear() diff --git a/homeassistant/components/media_player/nad.py b/homeassistant/components/media_player/nad.py index 43355782d29c4..2f0c49b258359 100644 --- a/homeassistant/components/media_player/nad.py +++ b/homeassistant/components/media_player/nad.py @@ -17,7 +17,7 @@ CONF_NAME, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['nad_receiver==0.0.6'] +REQUIREMENTS = ['nad_receiver==0.0.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/nadtcp.py b/homeassistant/components/media_player/nadtcp.py index a59a032f62459..06ec3c04cbe57 100644 --- a/homeassistant/components/media_player/nadtcp.py +++ b/homeassistant/components/media_player/nadtcp.py @@ -5,17 +5,17 @@ https://home-assistant.io/components/media_player.nadtcp/ """ import logging + import voluptuous as vol + from homeassistant.components.media_player import ( - SUPPORT_VOLUME_SET, - SUPPORT_VOLUME_MUTE, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, - SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, - PLATFORM_SCHEMA) -from homeassistant.const import ( - CONF_NAME, STATE_OFF, STATE_ON) + PLATFORM_SCHEMA, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, + MediaPlayerDevice) +from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['nad_receiver==0.0.6'] +REQUIREMENTS = ['nad_receiver==0.0.9'] _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,6 @@ CONF_MIN_VOLUME = 'min_volume' CONF_MAX_VOLUME = 'max_volume' CONF_VOLUME_STEP = 'volume_step' -CONF_HOST = 'host' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -42,7 +41,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the NAD platform.""" + """Set up the NAD platform.""" from nad_receiver import NADReceiverTCP add_devices([NADtcp( NADReceiverTCP(config.get(CONF_HOST)), diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index 97ebe5be92b04..71b74868544af 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -6,11 +6,15 @@ """ import logging +# pylint: disable=unused-import +from typing import List # noqa: F401 + 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 @@ -19,11 +23,14 @@ _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', @@ -35,10 +42,40 @@ 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.""" @@ -50,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: @@ -67,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 @@ -75,6 +134,7 @@ def __init__(self, receiver, sources, name=None): 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 @@ -95,8 +155,9 @@ def command(self, command): 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': @@ -104,9 +165,11 @@ def update(self): 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 @@ -125,7 +188,7 @@ def update(self): self._current_source = '_'.join( [i for i in current_source_tuples[1]]) self._muted = bool(mute_raw[1] == 'on') - self._volume = volume_raw[1] / 80.0 + self._volume = volume_raw[1] / self._max_volume @property def name(self): @@ -144,12 +207,12 @@ def volume_level(self): @property def is_volume_muted(self): - """Boolean if volume is currently muted.""" + """Return boolean indicating mute status.""" return self._muted @property def supported_features(self): - """Flag media player features that are supported.""" + """Return media player features that are supported.""" return SUPPORT_ONKYO @property @@ -163,12 +226,25 @@ def source_list(self): return self._source_list def turn_off(self): - """Turn off media player.""" + """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.""" - self.command('volume {}'.format(int(volume*80))) + """ + 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.""" @@ -186,3 +262,82 @@ def select_source(self, 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 + + # 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] / 80.0 + + def turn_off(self): + """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('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('zone{}.muting=on'.format(self._zone)) + else: + self.command('zone{}.muting=off'.format(self._zone)) + + def turn_on(self): + """Turn the media player 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('zone{}.selector={}'.format(self._zone, source)) diff --git a/homeassistant/components/media_player/openhome.py b/homeassistant/components/media_player/openhome.py index b2242bfecad55..5e30f9783c758 100644 --- a/homeassistant/components/media_player/openhome.py +++ b/homeassistant/components/media_player/openhome.py @@ -124,7 +124,7 @@ def media_stop(self): self._device.Stop() def media_play(self): - """Send play commmand.""" + """Send play command.""" self._device.Play() def media_next_track(self): @@ -156,7 +156,7 @@ def should_poll(self): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._device.Uuid() @property diff --git a/homeassistant/components/media_player/panasonic_viera.py b/homeassistant/components/media_player/panasonic_viera.py index 8c946ec0f0f1c..db60de922d998 100644 --- a/homeassistant/components/media_player/panasonic_viera.py +++ b/homeassistant/components/media_player/panasonic_viera.py @@ -11,14 +11,15 @@ from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PLAY, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MEDIA_TYPE_URL, + SUPPORT_PLAY_MEDIA, SUPPORT_STOP, SUPPORT_VOLUME_STEP, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_PORT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['panasonic_viera==0.2', - 'wakeonlan==0.2.2'] +REQUIREMENTS = ['panasonic_viera==0.3.1', + 'wakeonlan==1.0.0'] _LOGGER = logging.getLogger(__name__) @@ -30,7 +31,8 @@ SUPPORT_VIERATV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ - SUPPORT_TURN_OFF | SUPPORT_PLAY + SUPPORT_TURN_OFF | SUPPORT_PLAY | \ + SUPPORT_PLAY_MEDIA | SUPPORT_STOP PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -51,10 +53,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if discovery_info: _LOGGER.debug('%s', discovery_info) + 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) @@ -67,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.""" - from wakeonlan import wol + import wakeonlan # Save a reference to the imported class - self._wol = wol + 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: @@ -135,20 +149,20 @@ def turn_on(self): 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.""" @@ -169,17 +183,33 @@ def media_play_pause(self): 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.""" + _LOGGER.debug("Play media: %s (%s)", media_id, media_type) + + if media_type == MEDIA_TYPE_URL: + try: + self._remote.open_webpage(media_id) + except (TimeoutError, OSError): + self._state = STATE_OFF + else: + _LOGGER.warning("Unsupported media_type: %s", media_type) + + def media_stop(self): + """Stop playback.""" + self.send_key('NRC_CANCEL-ONOFF') diff --git a/homeassistant/components/media_player/philips_js.py b/homeassistant/components/media_player/philips_js.py index da572896ee0fd..d526fbb0387f3 100644 --- a/homeassistant/components/media_player/philips_js.py +++ b/homeassistant/components/media_player/philips_js.py @@ -12,13 +12,14 @@ 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_VOLUME_MUTE, - SUPPORT_VOLUME_STEP, SUPPORT_PLAY, MediaPlayerDevice) + SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_PLAY, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) + 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.1'] +REQUIREMENTS = ['ha-philipsjs==0.0.3'] _LOGGER = logging.getLogger(__name__) @@ -30,14 +31,18 @@ SUPPORT_PHILIPS_JS_TV = SUPPORT_PHILIPS_JS | SUPPORT_NEXT_TRACK | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY +CONF_ON_ACTION = 'turn_on_action' + DEFAULT_DEVICE = 'default' DEFAULT_HOST = '127.0.0.1' DEFAULT_NAME = 'Philips TV' -BASE_URL = 'http://{0}:1925/1/{1}' +DEFAULT_API_VERSION = '1' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_API_VERSION, default=DEFAULT_API_VERSION): cv.string, + vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, }) @@ -48,16 +53,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) host = config.get(CONF_HOST) + api_version = config.get(CONF_API_VERSION) + turn_on_action = config.get(CONF_ON_ACTION) - tvapi = haphilipsjs.PhilipsTV(host) + tvapi = haphilipsjs.PhilipsTV(host, api_version) + on_script = Script(hass, turn_on_action) if turn_on_action else None - add_devices([PhilipsTV(tvapi, name)]) + add_devices([PhilipsTV(tvapi, name, on_script)]) class PhilipsTV(MediaPlayerDevice): """Representation of a Philips TV exposing the JointSpace API.""" - def __init__(self, tv, name): + def __init__(self, tv, name, on_script): """Initialize the Philips TV.""" self._tv = tv self._name = name @@ -74,6 +82,7 @@ def __init__(self, tv, name): self._source_mapping = {} self._watching_tv = None self._channel_name = None + self._on_script = on_script @property def name(self): @@ -88,9 +97,10 @@ def should_poll(self): @property def supported_features(self): """Flag media player features that are supported.""" + is_supporting_turn_on = SUPPORT_TURN_ON if self._on_script else 0 if self._watching_tv: - return SUPPORT_PHILIPS_JS_TV - return SUPPORT_PHILIPS_JS + return SUPPORT_PHILIPS_JS_TV | is_supporting_turn_on + return SUPPORT_PHILIPS_JS | is_supporting_turn_on @property def state(self): @@ -126,6 +136,11 @@ def is_volume_muted(self): """Boolean if volume is currently muted.""" return self._muted + def turn_on(self): + """Turn on the device.""" + if self._on_script: + self._on_script.run() + def turn_off(self): """Turn off the device.""" self._tv.sendKey('Standby') @@ -151,11 +166,11 @@ def mute_volume(self, mute): self._state = STATE_OFF def media_previous_track(self): - """Send rewind commmand.""" + """Send rewind command.""" self._tv.sendKey('Previous') def media_next_track(self): - """Send fast forward commmand.""" + """Send fast forward command.""" self._tv.sendKey('Next') @property diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index a901cd1d569f3..6690382846fd1 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -6,16 +6,15 @@ """ import json import logging -import os + from datetime import timedelta -from urllib.parse import urlparse import requests 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,8 +22,11 @@ DEVICE_DEFAULT_NAME, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) 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==2.0.2'] +REQUIREMENTS = ['plexapi==3.0.6'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -38,6 +40,8 @@ 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,38 +50,22 @@ 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), }) - -def config_from_file(filename, config=None): - """Small configuration file management function.""" - if config: - # We're writing configuration - try: - with open(filename, 'w') as fdesc: - fdesc.write(json.dumps(config)) - except IOError as error: - _LOGGER.error("Saving config file failed: %s", error) - return False - return True - else: - # We're reading config - if os.path.isfile(filename): - try: - with open(filename, 'r') as fdesc: - return json.loads(fdesc.read()) - except IOError as error: - _LOGGER.error("Reading config file failed: %s", error) - # This won't work yet - return False - else: - return {} +PLEX_DATA = "plex" def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the Plex platform.""" + if PLEX_DATA not in hass.data: + hass.data[PLEX_DATA] = {} + # get config from plex.conf - file_config = config_from_file(hass.config.path(PLEX_CONFIG_FILE)) + file_config = load_json(hass.config.path(PLEX_CONFIG_FILE)) if file_config: # Setup a configured PlexServer @@ -147,17 +135,16 @@ def setup_plexserver( _LOGGER.info("Discovery configuration done") # Save config - if not config_from_file( - hass.config.path(PLEX_CONFIG_FILE), {host: { - 'token': token, - 'ssl': has_ssl, - 'verify': verify_ssl, - }}): - _LOGGER.error("Failed to save configuration file") + save_json( + hass.config.path(PLEX_CONFIG_FILE), {host: { + 'token': token, + 'ssl': has_ssl, + 'verify': verify_ssl, + }}) _LOGGER.info('Connected to: %s://%s', http_prefix, host) - plex_clients = {} + plex_clients = hass.data[PLEX_DATA] plex_sessions = {} track_utc_time_change(hass, lambda now: update_devices(), second=30) @@ -175,11 +162,14 @@ def update_devices(): return new_plex_clients = [] + available_client_ids = [] for device in devices: # For now, let's allow all deviceClass types if device.deviceClass in ['badClient']: continue + available_client_ids.append(device.machineIdentifier) + if device.machineIdentifier not in plex_clients: new_client = PlexClient(config, device, None, plex_sessions, update_devices, @@ -202,11 +192,27 @@ def update_devices(): else: plex_clients[machine_identifier].refresh(None, session) - for machine_identifier, client in plex_clients.items(): + 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: client.force_idle() + 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) @@ -225,9 +231,8 @@ def update_sessions(): plex_sessions.clear() for session in sessions: - if (session.player is not None and - session.player.machineIdentifier is not None): - plex_sessions[session.player.machineIdentifier] = session + for player in session.players: + plex_sessions[player.machineIdentifier] = session update_sessions() update_devices() @@ -255,7 +260,7 @@ def plex_configuration_callback(data): _CONFIGURING[host] = configurator.request_config( 'Plex Media Server', plex_configuration_callback, - description=('Enter the X-Plex-Token'), + description='Enter the X-Plex-Token', entity_picture='/static/images/logo_plex_mediaserver.png', submit_caption='Confirm', fields=[{ @@ -279,20 +284,16 @@ class PlexClient(MediaPlayerDevice): def __init__(self, config, device, session, plex_sessions, update_devices, update_sessions): """Initialize the Plex device.""" - from plexapi.utils import NA 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 + self._player = None self._machine_identifier = None self._make = '' - self._media_content_id = None - self._media_content_rating = None - self._media_content_type = None - self._media_duration = None - self._media_image_url = None - self._media_title = None self._name = None self._player_state = 'idle' self._previous_volume_level = 1 # Used in fake muting @@ -302,18 +303,23 @@ def __init__(self, config, device, session, plex_sessions, self._state = STATE_IDLE self._volume_level = 1 # since we can't retrieve remotely self._volume_muted = False # since we can't retrieve remotely - self.na_type = NA self.config = config self.plex_sessions = plex_sessions self.update_devices = update_devices self.update_sessions = update_sessions - + # General + self._media_content_id = None + self._media_content_rating = None + self._media_content_type = None + self._media_duration = None + self._media_image_url = None + self._media_title = None + self._media_position = None # Music self._media_album_artist = None self._media_album_name = None self._media_artist = None self._media_track = None - # TV Show self._media_episode = None self._media_season = None @@ -339,53 +345,109 @@ def __init__(self, config, device, session, plex_sessions, 'media_player', prefix, self.name.lower().replace('-', '_')) + def _clear_media_details(self): + """Set all Media Items to None.""" + # General + self._media_content_id = None + self._media_content_rating = None + self._media_content_type = None + self._media_duration = None + self._media_image_url = None + self._media_title = None + self._media_position = None + # Music + self._media_album_artist = None + self._media_album_name = None + self._media_artist = None + self._media_track = None + # TV Show + self._media_episode = None + self._media_season = None + self._media_series_title = None + + # Clear library Name + self._app_name = '' + def refresh(self, device, session): """Refresh key device data.""" # new data refresh - if session: + self._clear_media_details() + + if session: # Not being triggered by Chrome or FireTablet Plex App self._session = session if device: self._device = device + if "127.0.0.1" in self._device.url("/"): + self._device.proxyThroughServer() self._session = None - - if self._device: - self._machine_identifier = self._convert_na_to_none( - self._device.machineIdentifier) - self._name = self._convert_na_to_none( - self._device.title) or DEVICE_DEFAULT_NAME + self._machine_identifier = self._device.machineIdentifier + self._name = self._device.title or DEVICE_DEFAULT_NAME self._device_protocol_capabilities = ( self._device.protocolCapabilities) - # set valid session, preferring device session - if self._device and self.plex_sessions.get( - self._device.machineIdentifier, None): - self._session = self._convert_na_to_none(self.plex_sessions.get( - self._device.machineIdentifier, None)) + # set valid session, preferring device session + if self.plex_sessions.get(self._device.machineIdentifier, None): + self._session = self.plex_sessions.get( + self._device.machineIdentifier, None) if self._session: - self._media_position = self._convert_na_to_none( - self._session.viewOffset) - self._media_content_id = self._convert_na_to_none( - self._session.ratingKey) - self._media_content_rating = self._convert_na_to_none( - self._session.contentRating) + if self._device.machineIdentifier is not None and \ + self._session.players: + self._is_player_available = True + self._player = [p for p in self._session.players + if p.machineIdentifier == + self._device.machineIdentifier][0] + self._name = self._player.title + self._player_state = self._player.state + self._session_username = self._session.usernames[0] + self._make = self._player.device + else: + self._is_player_available = False + self._media_position = self._session.viewOffset + self._media_content_id = self._session.ratingKey + self._media_content_rating = getattr( + self._session, 'contentRating', None) + + self._set_player_state() + + if self._is_player_active and self._session is not None: + self._session_type = self._session.type + self._media_duration = self._session.duration + # title (movie name, tv episode name, music song name) + self._media_title = self._session.title + # media type + self._set_media_type() + self._app_name = self._session.section().title \ + if self._session.section() is not None else '' + self._set_media_image() else: - self._media_position = None - self._media_content_id = None - - # player dependent data - if self._session and self._session.player: - self._is_player_available = True - self._machine_identifier = self._convert_na_to_none( - self._session.player.machineIdentifier) - self._name = self._convert_na_to_none(self._session.player.title) - self._player_state = self._session.player.state - self._session_username = self._convert_na_to_none( - self._session.username) - self._make = self._convert_na_to_none(self._session.player.device) + self._session_type = None + + def _set_media_image(self): + thumb_url = self._session.thumbUrl + if (self.media_content_type is MEDIA_TYPE_TVSHOW + and not self.config.get(CONF_USE_EPISODE_ART)): + thumb_url = self._session.url(self._session.grandparentThumb) + + if thumb_url is None: + _LOGGER.debug("Using media art because media thumb " + "was not found: %s", self.entity_id) + thumb_url = self.session.url(self._session.art) + + self._media_image_url = thumb_url + + def set_availability(self, available): + """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._is_player_available = False + self._marked_unavailable = None + + self._available = available + def _set_player_state(self): if self._player_state == 'playing': self._is_player_active = True self._state = STATE_PLAYING @@ -399,134 +461,57 @@ def refresh(self, device, session): self._is_player_active = False self._state = STATE_OFF - if self._is_player_active and self._session is not None: - self._session_type = self._session.type - self._media_duration = self._convert_na_to_none( - self._session.duration) - else: - self._session_type = None - self._media_duration = None - - # media type - if self._session_type == 'clip': - _LOGGER.debug("Clip content type detected, compatibility may " - "vary: %s", self.entity_id) + def _set_media_type(self): + if self._session_type in ['clip', 'episode']: self._media_content_type = MEDIA_TYPE_TVSHOW - elif self._session_type == 'episode': - self._media_content_type = MEDIA_TYPE_TVSHOW - elif self._session_type == 'movie': - self._media_content_type = MEDIA_TYPE_VIDEO - elif self._session_type == 'track': - self._media_content_type = MEDIA_TYPE_MUSIC - else: - self._media_content_type = None - - # title (movie name, tv episode name, music song name) - if self._session: - self._media_title = self._convert_na_to_none(self._session.title) - - # Movies - if (self.media_content_type == MEDIA_TYPE_VIDEO and - self._convert_na_to_none(self._session.year) is not None): - self._media_title += ' (' + str(self._session.year) + ')' - - # TV Show - if (self._is_player_active and - self._media_content_type is MEDIA_TYPE_TVSHOW): # season number (00) - if callable(self._convert_na_to_none(self._session.seasons)): - self._media_season = self._convert_na_to_none( - self._session.seasons()[0].index).zfill(2) - elif self._convert_na_to_none( - self._session.parentIndex) is not None: + if callable(self._session.season): + self._media_season = str( + (self._session.season()).index).zfill(2) + elif self._session.parentIndex is not None: self._media_season = self._session.parentIndex.zfill(2) else: self._media_season = None - # show name - self._media_series_title = self._convert_na_to_none( - self._session.grandparentTitle) - + self._media_series_title = self._session.grandparentTitle # episode number (00) - if self._convert_na_to_none( - self._session.index) is not None: + if self._session.index is not None: self._media_episode = str(self._session.index).zfill(2) - else: - self._media_season = None - self._media_series_title = None - self._media_episode = None - # Music - if (self._is_player_active and - self._media_content_type == MEDIA_TYPE_MUSIC): - self._media_album_name = self._convert_na_to_none( - self._session.parentTitle) - self._media_album_artist = self._convert_na_to_none( - self._session.grandparentTitle) - self._media_track = self._convert_na_to_none(self._session.index) - self._media_artist = self._convert_na_to_none( - self._session.originalTitle) + elif self._session_type == 'movie': + 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) + ')' + + elif self._session_type == 'track': + self._media_content_type = MEDIA_TYPE_MUSIC + self._media_album_name = self._session.parentTitle + self._media_album_artist = self._session.grandparentTitle + self._media_track = self._session.index + self._media_artist = self._session.originalTitle # use album artist if track artist is missing if self._media_artist is None: - _LOGGER.debug("Using album artist because track artist was " - "not found: %s", self.entity_id) - self._media_artist = self._media_album_artist - else: - self._media_album_name = None - self._media_album_artist = None - self._media_track = None - self._media_artist = None - - # set app name to library name - if (self._session is not None - and self._session.librarySectionID is not None): - self._app_name = self._convert_na_to_none( - self._session.server.library.sectionByID( - self._session.librarySectionID).title) - else: - self._app_name = '' - - # media image url - if self._session is not None: - thumb_url = self._get_thumbnail_url(self._session.thumb) - if (self.media_content_type is MEDIA_TYPE_TVSHOW - and not self.config.get(CONF_USE_EPISODE_ART)): - thumb_url = self._get_thumbnail_url( - self._session.grandparentThumb) - - if thumb_url is None: - _LOGGER.debug("Using media art because media thumb " + _LOGGER.debug("Using album artist because track artist " "was not found: %s", self.entity_id) - thumb_url = self._get_thumbnail_url(self._session.art) - - self._media_image_url = thumb_url - else: - self._media_image_url = None - - def _get_thumbnail_url(self, property_value): - """Return full URL (if exists) for a thumbnail property.""" - if self._convert_na_to_none(property_value) is None: - return None - - if self._session is None or self._session.server is None: - return None - - url = self._session.server.url(property_value) - response = requests.get(url, verify=False) - if response and response.status_code == 200: - return url + self._media_artist = self._media_album_artist def force_idle(self): """Force client to idle.""" self._state = STATE_IDLE self._session = None + self._clear_media_details() @property def unique_id(self): """Return the id of this plex client.""" - return '{}.{}'.format(self.__class__, self.machine_identifier or - self.name) + return self.machine_identifier + + @property + def available(self): + """Return the availability of the client.""" + return self._available @property def name(self): @@ -548,6 +533,11 @@ def device(self): """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.""" @@ -563,17 +553,6 @@ def update(self): self.update_devices(no_throttle=True) self.update_sessions(no_throttle=True) - # pylint: disable=no-self-use, singleton-comparison - def _convert_na_to_none(self, value): - """Convert PlexAPI _NA() instances to None.""" - # PlexAPI will return a "__NA__" object which can be compared to - # None, but isn't actually None - this converts it to a real None - # type so that lower layers don't think it's a URL and choke on it - if value is self.na_type: - return None - - return value - @property def _active_media_plexapi_type(self): """Get the active media type required by PlexAPI commands.""" @@ -597,7 +576,7 @@ def media_content_type(self): 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 @@ -700,32 +679,9 @@ def supported_features(self): return None - def _local_client_control_fix(self): - """Detect if local client and adjust url to allow control.""" - if self.device is None: - return - - # if this device's machineIdentifier matches an active client - # with a loopback address, the device must be local or casting - for client in self.device.server.clients(): - if ("127.0.0.1" in client.baseurl and - client.machineIdentifier == self.device.machineIdentifier): - # point controls to server since that's where the - # playback is occuring - _LOGGER.debug( - "Local client detected, redirecting controls to " - "Plex server: %s", self.entity_id) - server_url = self.device.server.baseurl - client_url = self.device.baseurl - self.device.baseurl = "{}://{}:{}".format( - urlparse(client_url).scheme, - urlparse(server_url).hostname, - str(urlparse(client_url).port)) - def set_volume_level(self, volume): """Set volume level, range 0..1.""" if self.device and 'playback' in self._device_protocol_capabilities: - self._local_client_control_fix() self.device.setVolume( int(volume * 100), self._active_media_plexapi_type) self._volume_level = volume # store since we can't retrieve @@ -764,19 +720,16 @@ def mute_volume(self, mute): def media_play(self): """Send play command.""" if self.device and 'playback' in self._device_protocol_capabilities: - self._local_client_control_fix() self.device.play(self._active_media_plexapi_type) def media_pause(self): """Send pause command.""" if self.device and 'playback' in self._device_protocol_capabilities: - self._local_client_control_fix() self.device.pause(self._active_media_plexapi_type) def media_stop(self): """Send stop command.""" if self.device and 'playback' in self._device_protocol_capabilities: - self._local_client_control_fix() self.device.stop(self._active_media_plexapi_type) def turn_off(self): @@ -787,13 +740,11 @@ def turn_off(self): def media_next_track(self): """Send next track command.""" if self.device and 'playback' in self._device_protocol_capabilities: - self._local_client_control_fix() self.device.skipNext(self._active_media_plexapi_type) def media_previous_track(self): """Send previous track command.""" if self.device and 'playback' in self._device_protocol_capabilities: - self._local_client_control_fix() self.device.skipPrevious(self._active_media_plexapi_type) # pylint: disable=W0613 @@ -889,8 +840,6 @@ def _client_play_media(self, media, delete=False, **params): if delete: media.delete() - self._local_client_control_fix() - server_url = self.device.server.baseurl.split(':') self.device.sendCommand('playback/playMedia', **dict({ 'machineIdentifier': self.device.server.machineIdentifier, @@ -905,9 +854,10 @@ def _client_play_media(self, media, delete=False, **params): @property def device_state_attributes(self): """Return the scene state attributes.""" - attr = {} - attr['media_content_rating'] = self._media_content_rating - attr['session_username'] = self._session_username - attr['media_library_name'] = self._app_name + attr = { + 'media_content_rating': self._media_content_rating, + 'session_username': self._session_username, + 'media_library_name': self._app_name + } return attr diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index 5917f1e308344..a46e781de595e 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -9,14 +9,14 @@ 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 ( CONF_HOST, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, STATE_HOME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-roku==3.1.3'] +REQUIREMENTS = ['python-roku==3.1.5'] KNOWN_HOSTS = [] DEFAULT_PORT = 8060 @@ -146,6 +146,11 @@ def supported_features(self): """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 @@ def media_content_type(self): 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/russound_rio.py b/homeassistant/components/media_player/russound_rio.py index 743fc4e262def..31b04ceb3cdee 100644 --- a/homeassistant/components/media_player/russound_rio.py +++ b/homeassistant/components/media_player/russound_rio.py @@ -20,7 +20,7 @@ CONF_NAME, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['russound_rio==0.1.3'] +REQUIREMENTS = ['russound_rio==0.1.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/russound_rnet.py b/homeassistant/components/media_player/russound_rnet.py index 9ce3dcfc4f46a..932872467bd7d 100644 --- a/homeassistant/components/media_player/russound_rnet.py +++ b/homeassistant/components/media_player/russound_rnet.py @@ -15,7 +15,7 @@ CONF_HOST, CONF_PORT, STATE_OFF, STATE_ON, CONF_NAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['russound==0.1.7'] +REQUIREMENTS = ['russound==0.1.9'] _LOGGER = logging.getLogger(__name__) @@ -85,23 +85,30 @@ def __init__(self, hass, russ, sources, zone_id, extra): def update(self): """Retrieve latest state.""" - if self._russ.get_power('1', self._zone_id) == 0: - self._state = STATE_OFF - else: - self._state = STATE_ON - - self._volume = self._russ.get_volume('1', self._zone_id) / 100.0 - + # Updated this function to make a single call to get_zone_info, so that + # with a single call we can get On/Off, Volume and Source, reducing the + # amount of traffic and speeding up the update process. + ret = self._russ.get_zone_info('1', self._zone_id, 4) + _LOGGER.debug("ret= %s", ret) + if ret is not None: + _LOGGER.debug("Updating status for zone %s", self._zone_id) + if ret[0] == 0: + self._state = STATE_OFF + else: + self._state = STATE_ON + self._volume = ret[2] * 2 / 100.0 # Returns 0 based index for source. - index = self._russ.get_source('1', self._zone_id) + index = ret[1] # Possibility exists that user has defined list of all sources. # If a source is set externally that is beyond the defined list then # an exception will be thrown. # In this case return and unknown source (None) - try: - self._source = self._sources[index] - except IndexError: - self._source = None + try: + self._source = self._sources[index] + except IndexError: + self._source = None + else: + _LOGGER.error("Could not update status for zone %s", self._zone_id) @property def name(self): @@ -135,7 +142,7 @@ def volume_level(self): def set_volume_level(self, volume): """Set volume level. Volume has a range (0..1). - Translate this to a range of (0..100) as expected expected + Translate this to a range of (0..100) as expected by _russ.set_volume() """ self._russ.set_volume('1', self._zone_id, volume * 100) diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 0153eb687fffc..0b7fc3c078e81 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -6,7 +6,11 @@ """ import logging import socket +from datetime import timedelta +import sys + +import subprocess import voluptuous as vol from homeassistant.components.media_player import ( @@ -17,8 +21,9 @@ CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_PORT, CONF_MAC) import homeassistant.helpers.config_validation as cv +from homeassistant.util import dt as dt_util -REQUIREMENTS = ['samsungctl==0.6.0', 'wakeonlan==0.2.2'] +REQUIREMENTS = ['samsungctl[websocket]==0.7.1', 'wakeonlan==1.0.0'] _LOGGER = logging.getLogger(__name__) @@ -87,19 +92,22 @@ def __init__(self, host, port, name, timeout, mac): """Initialize the Samsung device.""" from samsungctl import exceptions from samsungctl import Remote - from wakeonlan import wol + import wakeonlan # Save a reference to the imported classes self._exceptions_class = exceptions self._remote_class = Remote self._name = name self._mac = mac - self._wol = wol + self._wol = wakeonlan # Assume that the TV is not muted self._muted = False # Assume that the TV is in Play mode self._playing = True self._state = STATE_UNKNOWN self._remote = None + # Mark the end of a shutdown command (need to wait 15 seconds before + # sending the next command to avoid turning the TV back ON). + self._end_of_power_off = None # Generate a configuration for the Samsung library self._config = { 'name': 'HomeAssistant', @@ -116,9 +124,21 @@ def __init__(self, host, port, name, timeout, mac): self._config['method'] = 'legacy' def update(self): - """Retrieve the latest data.""" - # Send an empty key to see if we are still connected - return self.send_key('KEY') + """Update state of device.""" + if sys.platform == 'win32': + _ping_cmd = ['ping', '-n 1', '-w', '1000', self._config['host']] + else: + _ping_cmd = ['ping', '-n', '-q', '-c1', '-W1', + self._config['host']] + + ping = subprocess.Popen( + _ping_cmd, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + try: + ping.communicate() + self._state = STATE_ON if ping.returncode == 0 else STATE_OFF + except subprocess.CalledProcessError: + self._state = STATE_OFF def get_remote(self): """Create or return a remote control instance.""" @@ -130,6 +150,10 @@ def get_remote(self): def send_key(self, key): """Send a key to the tv and handles exceptions.""" + if self._power_off_in_progress() \ + and not (key == 'KEY_POWER' or key == 'KEY_POWEROFF'): + _LOGGER.info("TV is powering off, not sending command: %s", key) + return try: self.get_remote().control(key) self._state = STATE_ON @@ -139,13 +163,16 @@ def send_key(self, key): # BrokenPipe can occur when the commands is sent to fast self._state = STATE_ON self._remote = None - return False + return except (self._exceptions_class.ConnectionClosed, OSError): self._state = STATE_OFF self._remote = None - return False + if self._power_off_in_progress(): + self._state = STATE_OFF - return True + def _power_off_in_progress(self): + return self._end_of_power_off is not None and \ + self._end_of_power_off > dt_util.utcnow() @property def name(self): @@ -171,12 +198,17 @@ def supported_features(self): def turn_off(self): """Turn off media player.""" + self._end_of_power_off = dt_util.utcnow() + timedelta(seconds=15) + if self._config['method'] == 'websocket': self.send_key('KEY_POWER') else: self.send_key('KEY_POWEROFF') # Force closing of remote session to provide instant UI feedback - self.get_remote().close() + try: + self.get_remote().close() + except OSError: + _LOGGER.debug("Could not establish connection.") def volume_up(self): """Volume up the media player.""" diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 2cf3617cc6116..0a6c413a688b8 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -1,129 +1,128 @@ -# Describes the format for available media_player services +# Describes the format for available media player services turn_on: - description: Turn a media player power on - + description: Turn a media player power on. fields: entity_id: - description: Name(s) of entities to turn on + description: Name(s) of entities to turn on. example: 'media_player.living_room_chromecast' turn_off: - description: Turn a media player power off - + description: Turn a media player power off. fields: entity_id: - description: Name(s) of entities to turn off + description: Name(s) of entities to turn off. example: 'media_player.living_room_chromecast' toggle: - description: Toggles a media player power state - + description: Toggles a media player power state. fields: entity_id: - description: Name(s) of entities to toggle + description: Name(s) of entities to toggle. example: 'media_player.living_room_chromecast' volume_up: - description: Turn a media player volume up - + description: Turn a media player volume up. fields: entity_id: - description: Name(s) of entities to turn volume up on + description: Name(s) of entities to turn volume up on. example: 'media_player.living_room_sonos' volume_down: - description: Turn a media player volume down - + description: Turn a media player volume down. fields: entity_id: - description: Name(s) of entities to turn volume down on + description: Name(s) of entities to turn volume down on. example: 'media_player.living_room_sonos' volume_mute: - description: Mute a media player's volume - + description: Mute a media player's volume. fields: entity_id: - description: Name(s) of entities to mute + description: Name(s) of entities to mute. example: 'media_player.living_room_sonos' is_volume_muted: - description: True/false for mute/unmute + description: True/false for mute/unmute. example: true volume_set: - description: Set a media player's volume level - + description: Set a media player's volume level. fields: entity_id: - description: Name(s) of entities to set volume level on + description: Name(s) of entities to set volume level on. example: 'media_player.living_room_sonos' volume_level: - description: Volume level to set as float + description: Volume level to set as float. example: 0.6 media_play_pause: - description: Toggle media player play/pause state - + description: Toggle media player play/pause state. fields: entity_id: - description: Name(s) of entities to toggle play/pause state on + description: Name(s) of entities to toggle play/pause state on. example: 'media_player.living_room_sonos' media_play: description: Send the media player the command for play. - fields: entity_id: - description: Name(s) of entities to play on + description: Name(s) of entities to play on. example: 'media_player.living_room_sonos' media_pause: description: Send the media player the command for pause. - fields: entity_id: - description: Name(s) of entities to pause on + description: Name(s) of entities to pause on. example: 'media_player.living_room_sonos' media_stop: description: Send the media player the stop command. - fields: entity_id: - description: Name(s) of entities to stop on + description: Name(s) of entities to stop on. example: 'media_player.living_room_sonos' media_next_track: description: Send the media player the command for next track. - fields: entity_id: - description: Name(s) of entities to send next track command to + description: Name(s) of entities to send next track command to. example: 'media_player.living_room_sonos' media_previous_track: description: Send the media player the command for previous track. - fields: entity_id: - description: Name(s) of entities to send previous track command to + description: Name(s) of entities to send previous track command to. example: 'media_player.living_room_sonos' media_seek: description: Send the media player the command to seek in current playing media. - fields: entity_id: - description: Name(s) of entities to seek media on + description: Name(s) of entities to seek media on. example: 'media_player.living_room_chromecast' seek_position: description: Position to seek to. The format is platform dependent. example: 100 +monoprice_snapshot: + description: Take a snapshot of the media player zone. + fields: + entity_id: + description: Name(s) of entities that will be snapshot. Platform dependent. + example: 'media_player.living_room' + +monoprice_restore: + description: Restore a snapshot of the media player zone. + fields: + entity_id: + description: Name(s) of entities that will be restored. Platform dependent. + example: 'media_player.living_room' + play_media: description: Send the media player the command for playing media. - fields: entity_id: description: Name(s) of entities to seek media on @@ -132,15 +131,14 @@ 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. - fields: entity_id: - description: Name(s) of entites to change source on + description: Name(s) of entities to change source on. example: 'media_player.media_player.txnr535_0009b0d81f82' source: description: Name of the source to switch to. Platform dependent. @@ -148,147 +146,165 @@ select_source: clear_playlist: description: Send the media player the command to clear players playlist. - fields: entity_id: - description: Name(s) of entites to change source on + description: Name(s) of entities to change source on. example: 'media_player.living_room_chromecast' shuffle_set: - description: Set shuffling state - + description: Set shuffling state. fields: entity_id: - description: Name(s) of entities to set + description: Name(s) of entities to set. example: 'media_player.spotify' shuffle: - description: True/false for enabling/disabling shuffle + description: True/false for enabling/disabling shuffle. example: true snapcast_snapshot: description: Take a snapshot of the media player. - fields: entity_id: - description: Name(s) of entites that will be snapshotted. Platform dependent. + description: Name(s) of entities that will be snapshotted. Platform dependent. example: 'media_player.living_room' snapcast_restore: description: Restore a snapshot of the media player. - fields: entity_id: - description: Name(s) of entites that will be restored. Platform dependent. + description: Name(s) of entities that will be restored. Platform dependent. example: 'media_player.living_room' sonos_join: description: Group player together. - fields: master: description: Entity ID of the player that should become the coordinator of the group. example: 'media_player.living_room_sonos' - entity_id: - description: Name(s) of entites that will coordinate the grouping. Platform dependent. + description: Name(s) of entities that will coordinate the grouping. Platform dependent. example: 'media_player.living_room_sonos' sonos_unjoin: description: Unjoin the player from a group. - fields: entity_id: - description: Name(s) of entites that will be unjoined from their group. Platform dependent. + description: Name(s) of entities that will be unjoined from their group. Platform dependent. example: 'media_player.living_room_sonos' sonos_snapshot: description: Take a snapshot of the media player. - fields: entity_id: - description: Name(s) of entites that will be snapshot. Platform dependent. + description: Name(s) of entities that will be snapshot. Platform dependent. example: 'media_player.living_room_sonos' - with_group: description: True (default) or False. Snapshot with all group attributes. example: 'true' sonos_restore: description: Restore a snapshot of the media player. - fields: entity_id: - description: Name(s) of entites that will be restored. Platform dependent. + description: Name(s) of entities that will be restored. Platform dependent. example: 'media_player.living_room_sonos' - with_group: description: True (default) or False. Restore with all group attributes. example: 'true' sonos_set_sleep_timer: - description: Set a Sonos timer - + description: Set a Sonos timer. fields: entity_id: - description: Name(s) of entites that will have a timer set. + description: Name(s) of entities that will have a timer set. example: 'media_player.living_room_sonos' sleep_time: - description: Number of seconds to set the timer + description: Number of seconds to set the timer. example: '900' sonos_clear_sleep_timer: - description: Clear a Sonos timer + description: Clear a Sonos timer. + fields: + entity_id: + description: Name(s) of entities that will have the timer cleared. + example: 'media_player.living_room_sonos' +sonos_set_option: + description: Set Sonos sound options. fields: entity_id: - description: Name(s) of entites that will have the timer cleared. + description: Name(s) of entities that will have options set. example: 'media_player.living_room_sonos' + night_sound: + description: Enable Night Sound mode + example: 'true' + speech_enhance: + description: Enable Speech Enhancement mode + example: 'true' +channels_seek_forward: + description: Seek forward by a set number of seconds. + fields: + entity_id: + description: Name of entity for the instance of Channels to seek in. + example: 'media_player.family_room_channels' -soundtouch_play_everywhere: - description: Play on all Bose Soundtouch devices +channels_seek_backward: + description: Seek backward by a set number of seconds. + fields: + entity_id: + description: Name of entity for the instance of Channels to seek in. + example: 'media_player.family_room_channels' +channels_seek_by: + description: Seek by an inputted number of seconds. + fields: + entity_id: + description: Name of entity for the instance of Channels to seek in. + example: 'media_player.family_room_channels' + seconds: + description: Number of seconds to seek by. Negative numbers seek backwards. + example: 120 + +soundtouch_play_everywhere: + description: Play on all Bose Soundtouch devices. fields: master: description: Name of the master entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices example: 'media_player.soundtouch_home' soundtouch_create_zone: - description: Create a multi-room zone - + description: Create a Sountouch multi-room zone. fields: master: description: Name of the master entity that will coordinate the multi-room zone. Platform dependent. example: 'media_player.soundtouch_home' slaves: - description: Name of slaves entities to add to the new zone + description: Name of slaves entities to add to the new zone. example: 'media_player.soundtouch_bedroom' soundtouch_add_zone_slave: - description: Add a slave to a multi-room zone - + description: Add a slave to a Sountouch multi-room zone. fields: master: description: Name of the master entity that is coordinating the multi-room zone. Platform dependent. example: 'media_player.soundtouch_home' slaves: - description: Name of slaves entities to add to the existing zone + description: Name of slaves entities to add to the existing zone. example: 'media_player.soundtouch_bedroom' soundtouch_remove_zone_slave: - description: Remove a slave from the multi-room zone - + description: Remove a slave from the Sounttouch multi-room zone. fields: master: description: Name of the master entity that is coordinating the multi-room zone. Platform dependent. example: 'media_player.soundtouch_home' slaves: - description: Name of slaves entities to remove from the existing zone + description: Name of slaves entities to remove from the existing zone. example: 'media_player.soundtouch_bedroom' kodi_add_to_playlist: description: Add music to the default playlist (i.e. playlistid=0). - fields: entity_id: description: Name(s) of the Kodi entities where to add the media. @@ -308,7 +324,6 @@ kodi_add_to_playlist: kodi_call_method: description: 'Call a Kodi JSONRPC API method with optional parameters. Results of the Kodi API call will be redirected in a Home Assistant event: `kodi_call_method_result`.' - fields: entity_id: description: Name(s) of the Kodi entities where to run the API method. @@ -316,3 +331,84 @@ kodi_call_method: method: description: Name of the Kodi JSONRPC API method to be called. example: 'VideoLibrary.GetRecentlyAddedEpisodes' + +squeezebox_call_method: + description: 'Call a Squeezebox JSON/RPC API method.' + fields: + entity_id: + description: Name(s) of the Squeexebox entities where to run the API method. + example: 'media_player.squeezebox_radio' + command: + description: Name of the Squeezebox command. + example: 'playlist' + parameters: + description: Optional array of parameters to be appended to the command. See 'Command Line Interface' official help page from Logitech for details. + example: '["loadtracks", "track.titlesearch=highway to hell"]' + +yamaha_enable_output: + description: Enable or disable an output port + fields: + entity_id: + description: Name(s) of entites to enable/disable port on. + example: 'media_player.yamaha' + port: + description: Name of port to enable/disable. + example: 'hdmi1' + enabled: + description: Boolean indicating if port should be enabled or not. + example: true + +bluesound_join: + description: Group player together. + fields: + master: + description: Entity ID of the player that should become the master of the group. + example: 'media_player.bluesound_livingroom' + entity_id: + description: Name(s) of entities that will coordinate the grouping. Platform dependent. + example: 'media_player.bluesound_livingroom' + +bluesound_unjoin: + description: Unjoin the player from a group. + fields: + entity_id: + description: Name(s) of entities that will be unjoined from their group. Platform dependent. + example: 'media_player.bluesound_livingroom' + +bluesound_set_sleep_timer: + description: "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0" + fields: + entity_id: + description: Name(s) of entities that will have a timer set. + example: 'media_player.bluesound_livingroom' + +bluesound_clear_sleep_timer: + description: Clear a Bluesound timer. + fields: + entity_id: + description: Name(s) of entities that will have the timer cleared. + example: 'media_player.bluesound_livingroom' + +songpal_set_sound_setting: + description: Change sound setting. + + fields: + entity_id: + description: Target device. + example: 'media_player.my_soundbar' + name: + description: Name of the setting. + example: 'nightMode' + 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/snapcast.py b/homeassistant/components/media_player/snapcast.py index 1715f0f18299b..793800a3d2259 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -6,25 +6,23 @@ """ import asyncio import logging -from os import path import socket import voluptuous as vol from homeassistant.components.media_player import ( - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, - PLATFORM_SCHEMA, MediaPlayerDevice) + DOMAIN, PLATFORM_SCHEMA, SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( - STATE_ON, STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, CONF_HOST, - CONF_PORT, ATTR_ENTITY_ID) + ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, STATE_IDLE, STATE_OFF, STATE_ON, + STATE_PLAYING, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['snapcast==2.0.7'] +REQUIREMENTS = ['snapcast==2.0.8'] _LOGGER = logging.getLogger(__name__) -DOMAIN = 'snapcast' +DATA_KEY = 'snapcast' SERVICE_SNAPSHOT = 'snapcast_snapshot' SERVICE_RESTORE = 'snapcast_restore' @@ -44,14 +42,14 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT): cv.port + vol.Optional(CONF_PORT): cv.port, }) # pylint: disable=unused-argument @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Setup the Snapcast platform.""" + """Set up the Snapcast platform.""" import snapcast.control from snapcast.control.server import CONTROL_PORT host = config.get(CONF_HOST) @@ -61,7 +59,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): def _handle_service(service): """Handle services.""" entity_ids = service.data.get(ATTR_ENTITY_ID) - devices = [device for device in hass.data[DOMAIN] + devices = [device for device in hass.data[DATA_KEY] if device.entity_id in entity_ids] for device in devices: if service.service == SERVICE_SNAPSHOT: @@ -69,28 +67,24 @@ def _handle_service(service): elif service.service == SERVICE_RESTORE: yield from device.async_restore() - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) hass.services.async_register( - DOMAIN, SERVICE_SNAPSHOT, _handle_service, - descriptions.get(SERVICE_SNAPSHOT), schema=SERVICE_SCHEMA) + DOMAIN, SERVICE_SNAPSHOT, _handle_service, schema=SERVICE_SCHEMA) hass.services.async_register( - DOMAIN, SERVICE_RESTORE, _handle_service, - descriptions.get(SERVICE_RESTORE), schema=SERVICE_SCHEMA) + DOMAIN, SERVICE_RESTORE, _handle_service, schema=SERVICE_SCHEMA) try: server = yield from snapcast.control.create_server( - hass.loop, host, port) + hass.loop, host, port, reconnect=True) except socket.gaierror: - _LOGGER.error('Could not connect to Snapcast server at %s:%d', + _LOGGER.error("Could not connect to Snapcast server at %s:%d", host, port) - return False + return + groups = [SnapcastGroupDevice(group) for group in server.groups] clients = [SnapcastClientDevice(client) for client in server.clients] devices = groups + clients - hass.data[DOMAIN] = devices + hass.data[DATA_KEY] = devices async_add_devices(devices) - return True class SnapcastGroupDevice(MediaPlayerDevice): @@ -159,19 +153,19 @@ def async_select_source(self, source): streams = self._group.streams_by_name() if source in streams: yield from self._group.set_stream(streams[source].identifier) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_mute_volume(self, mute): """Send the mute command.""" yield from self._group.set_muted(mute) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_volume_level(self, volume): """Set the volume level.""" yield from self._group.set_volume(round(volume * 100)) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() def snapshot(self): """Snapshot the group state.""" @@ -235,13 +229,13 @@ def should_poll(self): def async_mute_volume(self, mute): """Send the mute command.""" yield from self._client.set_muted(mute) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_volume_level(self, volume): """Set the volume level.""" yield from self._client.set_volume(round(volume * 100)) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() def snapshot(self): """Snapshot the client state.""" diff --git a/homeassistant/components/media_player/songpal.py b/homeassistant/components/media_player/songpal.py new file mode 100644 index 0000000000000..5d0962775f05c --- /dev/null +++ b/homeassistant/components/media_player/songpal.py @@ -0,0 +1,252 @@ +""" +Support for Songpal-enabled (Sony) media devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.songpal/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_VOLUME_SET, + SUPPORT_TURN_ON, MediaPlayerDevice, DOMAIN) +from homeassistant.const import ( + CONF_NAME, STATE_ON, STATE_OFF, ATTR_ENTITY_ID) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['python-songpal==0.0.7'] + +SUPPORT_SONGPAL = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | \ + SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF + +_LOGGER = logging.getLogger(__name__) + + +PLATFORM = "songpal" + +SET_SOUND_SETTING = "songpal_set_sound_setting" + +PARAM_NAME = "name" +PARAM_VALUE = "value" + +CONF_ENDPOINT = "endpoint" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_ENDPOINT): cv.string, +}) + +SET_SOUND_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(PARAM_NAME): cv.string, + vol.Required(PARAM_VALUE): cv.string}) + + +async def async_setup_platform(hass, config, + async_add_devices, discovery_info=None): + """Set up the Songpal platform.""" + from songpal import SongpalException + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {} + + if discovery_info is not None: + name = discovery_info["name"] + endpoint = discovery_info["properties"]["endpoint"] + _LOGGER.debug("Got autodiscovered %s - endpoint: %s", name, endpoint) + + device = SongpalDevice(name, endpoint) + else: + name = config.get(CONF_NAME) + endpoint = config.get(CONF_ENDPOINT) + device = SongpalDevice(name, endpoint) + + try: + await device.initialize() + except SongpalException as ex: + _LOGGER.error("Unable to get methods from songpal: %s", ex) + raise PlatformNotReady + + hass.data[PLATFORM][endpoint] = device + + async_add_devices([device], True) + + async def async_service_handler(service): + """Service handler.""" + entity_id = service.data.get("entity_id", None) + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + + for device in hass.data[PLATFORM].values(): + if device.entity_id == entity_id or entity_id is None: + _LOGGER.debug("Calling %s (entity: %s) with params %s", + service, entity_id, params) + + await device.async_set_sound_setting(params[PARAM_NAME], + params[PARAM_VALUE]) + + hass.services.async_register( + DOMAIN, SET_SOUND_SETTING, async_service_handler, + schema=SET_SOUND_SCHEMA) + + +class SongpalDevice(MediaPlayerDevice): + """Class representing a Songpal device.""" + + def __init__(self, name, endpoint): + """Init.""" + import songpal + self._name = name + self.endpoint = endpoint + self.dev = songpal.Device(self.endpoint) + self._sysinfo = None + + self._state = False + self._available = False + self._initialized = False + + self._volume_control = None + self._volume_min = 0 + self._volume_max = 1 + self._volume = 0 + self._is_muted = False + + self._sources = [] + + async def initialize(self): + """Initialize the device.""" + await self.dev.get_supported_methods() + self._sysinfo = await self.dev.get_system_info() + + @property + def name(self): + """Return name of the device.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return self._sysinfo.macAddr + + @property + def available(self): + """Return availability of the device.""" + return self._available + + async def async_set_sound_setting(self, name, value): + """Change a setting on the device.""" + await self.dev.set_sound_settings(name, value) + + async def async_update(self): + """Fetch updates from the device.""" + from songpal import SongpalException + try: + volumes = await self.dev.get_volume_information() + if not volumes: + _LOGGER.error("Got no volume controls, bailing out") + self._available = False + return + + if len(volumes) > 1: + _LOGGER.debug("Got %s volume controls, using the first one", + volumes) + + volume = volumes[0] + _LOGGER.debug("Current volume: %s", volume) + + self._volume_max = volume.maxVolume + self._volume_min = volume.minVolume + self._volume = volume.volume + self._volume_control = volume + self._is_muted = self._volume_control.is_muted + + status = await self.dev.get_power() + self._state = status.status + _LOGGER.debug("Got state: %s", status) + + inputs = await self.dev.get_inputs() + _LOGGER.debug("Got ins: %s", inputs) + self._sources = inputs + + self._available = True + except SongpalException as ex: + # if we were available, print out the exception + if self._available: + _LOGGER.error("Got an exception: %s", ex) + self._available = False + + async def async_select_source(self, source): + """Select source.""" + for out in self._sources: + if out.title == source: + await out.activate() + return + + _LOGGER.error("Unable to find output: %s", source) + + @property + def source_list(self): + """Return list of available sources.""" + return [x.title for x in self._sources] + + @property + def state(self): + """Return current state.""" + if self._state: + return STATE_ON + return STATE_OFF + + @property + def source(self): + """Return currently active source.""" + for out in self._sources: + if out.active: + return out.title + + return None + + @property + def volume_level(self): + """Return volume level.""" + volume = self._volume / self._volume_max + return volume + + async def async_set_volume_level(self, volume): + """Set volume level.""" + volume = int(volume * self._volume_max) + _LOGGER.debug("Setting volume to %s", volume) + return await self._volume_control.set_volume(volume) + + async def async_volume_up(self): + """Set volume up.""" + return await self._volume_control.set_volume("+1") + + async def async_volume_down(self): + """Set volume down.""" + return await self._volume_control.set_volume("-1") + + async def async_turn_on(self): + """Turn the device on.""" + return await self.dev.set_power(True) + + async def async_turn_off(self): + """Turn the device off.""" + return await self.dev.set_power(False) + + async def async_mute_volume(self, mute): + """Mute or unmute the device.""" + _LOGGER.debug("Set mute: %s", mute) + return await self._volume_control.set_mute(mute) + + @property + def is_volume_muted(self): + """Return whether the device is muted.""" + return self._is_muted + + @property + def supported_features(self): + """Return supported features.""" + return SUPPORT_SONGPAL diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index a5ef91ecc87a3..06e5f3befe474 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -8,42 +8,37 @@ import datetime import functools as ft import logging -from os import path import socket import urllib +import threading import voluptuous as vol from homeassistant.components.media_player import ( - ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, - SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_CLEAR_PLAYLIST, - SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_STOP, - SUPPORT_PLAY) + ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( - STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_OFF, ATTR_ENTITY_ID, - CONF_HOSTS, ATTR_TIME) -from homeassistant.config import load_yaml_config_file + ATTR_ENTITY_ID, ATTR_TIME, CONF_HOSTS, STATE_IDLE, STATE_OFF, STATE_PAUSED, + STATE_PLAYING) import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['SoCo==0.12'] +REQUIREMENTS = ['SoCo==0.14'] _LOGGER = logging.getLogger(__name__) -# The soco library is excessively chatty when it comes to logging and -# causes a LOT of spam in the logs due to making a http connection to each -# speaker every 10 seconds. Quiet it down a bit to just actual problems. -_SOCO_LOGGER = logging.getLogger('soco') -_SOCO_LOGGER.setLevel(logging.ERROR) +# Quiet down soco logging to just actual problems. +logging.getLogger('soco').setLevel(logging.WARNING) +logging.getLogger('soco.data_structures_entry').setLevel(logging.ERROR) _SOCO_SERVICES_LOGGER = logging.getLogger('soco.services') -_REQUESTS_LOGGER = logging.getLogger('requests') -_REQUESTS_LOGGER.setLevel(logging.ERROR) -SUPPORT_SONOS = SUPPORT_STOP | SUPPORT_PAUSE | SUPPORT_VOLUME_SET |\ - SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ - SUPPORT_PLAY_MEDIA | SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST |\ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY +SUPPORT_SONOS = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ + SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | SUPPORT_SELECT_SOURCE |\ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK |\ + SUPPORT_PLAY_MEDIA | SUPPORT_SHUFFLE_SET | SUPPORT_CLEAR_PLAYLIST SERVICE_JOIN = 'sonos_join' SERVICE_UNJOIN = 'sonos_unjoin' @@ -52,13 +47,13 @@ SERVICE_SET_TIMER = 'sonos_set_sleep_timer' SERVICE_CLEAR_TIMER = 'sonos_clear_sleep_timer' SERVICE_UPDATE_ALARM = 'sonos_update_alarm' +SERVICE_SET_OPTION = 'sonos_set_option' DATA_SONOS = 'sonos' -SUPPORT_SOURCE_LINEIN = 'Line-in' -SUPPORT_SOURCE_TV = 'TV' +SOURCE_LINEIN = 'Line-in' +SOURCE_TV = 'TV' -CONF_ADVERTISE_ADDR = 'advertise_addr' CONF_INTERFACE_ADDR = 'interface_addr' # Service call validation schemas @@ -69,13 +64,14 @@ ATTR_INCLUDE_LINKED_ZONES = 'include_linked_zones' ATTR_MASTER = 'master' 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'] +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]), }) @@ -105,44 +101,70 @@ vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, }) +SONOS_SET_OPTION_SCHEMA = SONOS_SCHEMA.extend({ + vol.Optional(ATTR_NIGHT_SOUND): cv.boolean, + vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean, +}) + + +class SonosData: + """Storage class for platform global data.""" + + def __init__(self): + """Initialize the data.""" + self.uids = set() + self.devices = [] + self.topology_lock = threading.Lock() + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sonos platform.""" import soco + import soco.events + import soco.exceptions - if DATA_SONOS not in hass.data: - hass.data[DATA_SONOS] = [] + orig_parse_event_xml = soco.events.parse_event_xml - advertise_addr = config.get(CONF_ADVERTISE_ADDR, None) - if advertise_addr: - soco.config.EVENT_ADVERTISE_IP = advertise_addr + def safe_parse_event_xml(xml): + """Avoid SoCo 0.14 event thread dying from invalid xml.""" + try: + return orig_parse_event_xml(xml) + # pylint: disable=broad-except + except Exception as ex: + _LOGGER.debug("Dodged exception: %s %s", type(ex), str(ex)) + return {} + + soco.events.parse_event_xml = safe_parse_event_xml + + if DATA_SONOS not in hass.data: + hass.data[DATA_SONOS] = SonosData() + players = [] if discovery_info: player = soco.SoCo(discovery_info.get('host')) - # if device already exists by config - if player.uid in [x.unique_id for x in hass.data[DATA_SONOS]]: + # If device already exists by config + if player.uid in hass.data[DATA_SONOS].uids: return - if player.is_visible: - device = SonosDevice(player) - add_devices([device], True) - hass.data[DATA_SONOS].append(device) - if len(hass.data[DATA_SONOS]) > 1: - return + # 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: - players.append(soco.SoCo(socket.gethostbyname(host))) - - if not players: + try: + players.append(soco.SoCo(socket.gethostbyname(host))) + except OSError: + _LOGGER.warning("Failed to initialize '%s'", host) + else: players = soco.discover( interface_addr=config.get(CONF_INTERFACE_ADDR)) @@ -150,30 +172,34 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.warning("No Sonos speakers found") return - hass.data[DATA_SONOS] = [SonosDevice(p) for p in players] - add_devices(hass.data[DATA_SONOS], True) - _LOGGER.info("Added %s Sonos speakers", len(players)) - - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) + 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.""" entity_ids = service.data.get('entity_id') + devices = hass.data[DATA_SONOS].devices if entity_ids: - devices = [device for device in hass.data[DATA_SONOS] - if device.entity_id in entity_ids] - else: - devices = hass.data[DATA_SONOS] + devices = [d for d in devices if d.entity_id in entity_ids] + + if service.service == SERVICE_JOIN: + master = [device for device in hass.data[DATA_SONOS].devices + if device.entity_id == service.data[ATTR_MASTER]] + if master: + 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_JOIN: - if device.entity_id != service.data[ATTR_MASTER]: - device.join(service.data[ATTR_MASTER]) - elif 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]) @@ -182,91 +208,73 @@ def service_handle(service): 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.set_option(**service.data) device.schedule_update_ha_state(True) hass.services.register( DOMAIN, SERVICE_JOIN, service_handle, - descriptions.get(SERVICE_JOIN), schema=SONOS_JOIN_SCHEMA) + schema=SONOS_JOIN_SCHEMA) hass.services.register( DOMAIN, SERVICE_UNJOIN, service_handle, - descriptions.get(SERVICE_UNJOIN), schema=SONOS_SCHEMA) + schema=SONOS_SCHEMA) hass.services.register( DOMAIN, SERVICE_SNAPSHOT, service_handle, - descriptions.get(SERVICE_SNAPSHOT), schema=SONOS_STATES_SCHEMA) + schema=SONOS_STATES_SCHEMA) hass.services.register( DOMAIN, SERVICE_RESTORE, service_handle, - descriptions.get(SERVICE_RESTORE), schema=SONOS_STATES_SCHEMA) + schema=SONOS_STATES_SCHEMA) hass.services.register( DOMAIN, SERVICE_SET_TIMER, service_handle, - descriptions.get(SERVICE_SET_TIMER), schema=SONOS_SET_TIMER_SCHEMA) + schema=SONOS_SET_TIMER_SCHEMA) hass.services.register( DOMAIN, SERVICE_CLEAR_TIMER, service_handle, - descriptions.get(SERVICE_CLEAR_TIMER), schema=SONOS_SCHEMA) + schema=SONOS_SCHEMA) hass.services.register( DOMAIN, SERVICE_UPDATE_ALARM, service_handle, - descriptions.get(SERVICE_UPDATE_ALARM), schema=SONOS_UPDATE_ALARM_SCHEMA) - -def _parse_timespan(timespan): - """Parse a time-span into number of seconds.""" - if timespan in ('', 'NOT_IMPLEMENTED', None): - return None - - return sum(60 ** x[0] * int(x[1]) for x in enumerate( - reversed(timespan.split(':')))) + hass.services.register( + DOMAIN, SERVICE_SET_OPTION, service_handle, + schema=SONOS_SET_OPTION_SCHEMA) -class _ProcessSonosEventQueue(): +class _ProcessSonosEventQueue: """Queue like object for dispatching sonos events.""" - def __init__(self, sonos_device): - self._sonos_device = sonos_device + def __init__(self, handler): + """Initialize Sonos event queue.""" + self._handler = handler def put(self, item, block=True, timeout=None): - """Queue up event for processing.""" - # Instead of putting events on a queue, dispatch them to the event - # processing method. - self._sonos_device.process_sonos_event(item) - + """Process event.""" + self._handler(item) -def _get_entity_from_soco(hass, soco): - """Return SonosDevice from SoCo.""" - for device in hass.data[DATA_SONOS]: - if soco == device.soco: - return device - raise ValueError("No entity for SoCo device") - -def soco_error(funct): - """Catch soco exceptions.""" - @ft.wraps(funct) - def wrapper(*args, **kwargs): - """Wrap for all soco exception.""" - from soco.exceptions import SoCoException - try: - return funct(*args, **kwargs) - except SoCoException as err: - _LOGGER.error("Error on %s with %s", funct.__name__, err) - return wrapper +def _get_entity_from_soco_uid(hass, uid): + """Return SonosDevice from SoCo uid.""" + for entity in hass.data[DATA_SONOS].devices: + if uid == entity.soco.uid: + return entity + return None -def soco_filter_upnperror(errorcodes=None): - """Filter out specified UPnP errors from logs.""" +def soco_error(errorcodes=None): + """Filter out specified UPnP errors from logs and avoid exceptions.""" def decorator(funct): - """Decorator function.""" + """Decorate functions.""" @ft.wraps(funct) def wrapper(*args, **kwargs): """Wrap for all soco UPnP exception.""" - from soco.exceptions import SoCoUPnPException + from soco.exceptions import SoCoUPnPException, SoCoException # Temporarily disable SoCo logging because it will log the # UPnP exception otherwise @@ -275,10 +283,12 @@ def wrapper(*args, **kwargs): try: return funct(*args, **kwargs) except SoCoUPnPException as err: - if err.error_code in errorcodes: + if errorcodes and err.error_code in errorcodes: pass else: - raise + _LOGGER.error("Error on %s with %s", funct.__name__, err) + except SoCoException as err: + _LOGGER.error("Error on %s with %s", funct.__name__, err) finally: _SOCO_SERVICES_LOGGER.disabled = False @@ -298,21 +308,40 @@ def wrapper(device, *args, **kwargs): return wrapper +def _timespan_secs(timespan): + """Parse a time-span into number of seconds.""" + if timespan in ('', 'NOT_IMPLEMENTED', None): + return None + + return sum(60 ** x[0] * int(x[1]) for x in enumerate( + reversed(timespan.split(':')))) + + +def _is_radio_uri(uri): + """Return whether the URI is a radio stream.""" + radio_schemes = ( + 'x-rincon-mp3radio:', 'x-sonosapi-stream:', 'x-sonosapi-radio:', + 'x-sonosapi-hls:', 'hls-radio:') + return uri.startswith(radio_schemes) + + class SonosDevice(MediaPlayerDevice): """Representation of a Sonos device.""" def __init__(self, player): """Initialize the Sonos device.""" - self.volume_increment = 5 + 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._speaker_info = None + self._player_muted = None + self._play_mode = None self._name = None - self._status = None self._coordinator = None - self._media_content_id = None + self._sonos_group = None + self._status = None self._media_duration = None self._media_position = None self._media_position_updated_at = None @@ -320,38 +349,25 @@ def __init__(self, player): self._media_artist = None self._media_album_name = None self._media_title = None - self._media_radio_show = None - self._media_next_title = None - self._available = True - self._support_previous_track = False - self._support_next_track = False - self._support_play = False - self._support_stop = False - self._support_pause = False - self._current_track_uri = None - self._current_track_is_radio_stream = False - self._queue = None - self._last_avtransport_event = None - self._is_playing_line_in = None - self._is_playing_tv = None - self._favorite_sources = None + self._night_sound = None + self._speech_enhance = None self._source_name = None + self._available = True + self._favorites = None self._soco_snapshot = None self._snapshot_group = None + self._set_basic_information() + @asyncio.coroutine def async_added_to_hass(self): """Subscribe sonos events.""" + self.hass.data[DATA_SONOS].devices.append(self) self.hass.async_add_job(self._subscribe_to_player_events) - @property - def should_poll(self): - """Return the polling state.""" - return True - @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._unique_id @property @@ -360,10 +376,9 @@ def name(self): return self._name @property + @soco_coordinator def state(self): """Return the state of the device.""" - if self._coordinator: - return self._coordinator.state if self._status in ('PAUSED_PLAYBACK', 'STOPPED'): return STATE_PAUSED if self._status in ('PLAYING', 'TRANSITIONING'): @@ -392,390 +407,325 @@ def available(self) -> bool: """Return True if entity is available.""" return self._available - def _is_available(self): + def _check_available(self): + """Check that we can still connect to the player.""" try: sock = socket.create_connection( - address=(self._player.ip_address, 1443), timeout=3) + address=(self.soco.ip_address, 1443), timeout=3) sock.close() return True except socket.error: return False - # pylint: disable=invalid-name + def _set_basic_information(self): + """Set initial device information.""" + speaker_info = self.soco.get_speaker_info(True) + self._name = speaker_info['zone_name'] + self._model = speaker_info['model_name'] + self._play_mode = self.soco.play_mode + + self.update_volume() + + self._favorites = [] + for fav in self.soco.music_library.get_sonos_favorites(): + # SoCo 0.14 raises a generic Exception on invalid xml in favorites. + # Filter those out now so our list is safe to use. + try: + if fav.reference.get_uri(): + self._favorites.append(fav) + # pylint: disable=broad-except + except Exception: + _LOGGER.debug("Ignoring invalid favorite '%s'", fav.title) + + def _radio_artwork(self, url): + """Return the private URL with artwork for a radio stream.""" + if url not in ('', 'NOT_IMPLEMENTED', None): + if url.find('tts_proxy') > 0: + # If the content is a tts don't try to fetch an image from it. + return None + url = 'http://{host}:{port}/getaa?s=1&u={uri}'.format( + host=self.soco.ip_address, + port=1400, + uri=urllib.parse.quote(url, safe='') + ) + return url + def _subscribe_to_player_events(self): - if self._queue is None: - self._queue = _ProcessSonosEventQueue(self) - self._player.avTransport.subscribe( - auto_renew=True, - event_queue=self._queue) - self._player.renderingControl.subscribe( - auto_renew=True, - event_queue=self._queue) + """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.""" - if self._speaker_info is None: - self._speaker_info = self._player.get_speaker_info(True) - self._name = self._speaker_info['zone_name'].replace( - ' (R)', '').replace(' (L)', '') - self._favorite_sources = \ - self._player.get_sonos_favorites()['favorites'] - - if self._last_avtransport_event: - self._available = True - else: - self._available = self._is_available() - - if not self._available: - self._player_volume = None - self._player_volume_muted = None - self._status = 'OFF' - self._coordinator = None - self._media_content_id = 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._media_radio_show = None - self._media_next_title = None - self._current_track_uri = None - self._current_track_is_radio_stream = False - self._support_previous_track = False - self._support_next_track = False - self._support_play = False - self._support_stop = False - self._support_pause = False - self._is_playing_tv = False - self._is_playing_line_in = False - self._source_name = None - self._last_avtransport_event = None + 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 - # set group coordinator - if self._player.is_coordinator: - self._coordinator = None + 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: - try: - self._coordinator = _get_entity_from_soco( - self.hass, self._player.group.coordinator) - - # protect for loop - if not self._coordinator.is_coordinator: - # pylint: disable=protected-access - self._coordinator._coordinator = None - except ValueError: - self._coordinator = None + track_info = self.soco.get_current_track_info() - track_info = None - if self._last_avtransport_event: - variables = self._last_avtransport_event.variables - current_track_metadata = variables.get( - 'current_track_meta_data', {} - ) + 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 = variables.get('transport_state') + self._status = new_status - if current_track_metadata: - # no need to ask speaker for information we already have - current_track_metadata = current_track_metadata.__dict__ - - track_info = { - 'uri': variables.get('current_track_uri'), - 'artist': current_track_metadata.get('creator'), - 'album': current_track_metadata.get('album'), - 'title': current_track_metadata.get('title'), - 'playlist_position': variables.get('current_track'), - 'duration': variables.get('current_track_duration') - } - else: - self._player_volume = self._player.volume - self._player_volume_muted = self._player.mute - transport_info = self._player.get_current_transport_info() - self._status = transport_info.get('current_transport_state') + self.schedule_update_ha_state() - if not track_info: - track_info = self._player.get_current_track_info() + # 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() - if self._coordinator: - self._last_avtransport_event = None - return + def update_media_linein(self, source): + """Update state when playing from line-in/tv.""" + self._media_duration = None + self._media_position = None + self._media_position_updated_at = None - is_playing_tv = self._player.is_playing_tv - is_playing_line_in = self._player.is_playing_line_in + self._media_image_url = None - media_info = self._player.avTransport.GetMediaInfo( - [('InstanceID', 0)] - ) + self._media_artist = source + self._media_album_name = None + self._media_title = None - current_media_uri = media_info['CurrentURI'] - media_artist = track_info.get('artist') - media_album_name = track_info.get('album') - media_title = track_info.get('title') - media_image_url = track_info.get('album_art', None) + self._source_name = source - media_position = None - media_position_updated_at = None - source_name = None + def update_media_radio(self, variables, track_info): + """Update state when streaming radio.""" + self._media_duration = None + self._media_position = None + self._media_position_updated_at = None - is_radio_stream = \ - current_media_uri.startswith('x-sonosapi-stream:') or \ - current_media_uri.startswith('x-rincon-mp3radio:') + media_info = self.soco.avTransport.GetMediaInfo([('InstanceID', 0)]) + self._media_image_url = self._radio_artwork(media_info['CurrentURI']) - if is_playing_tv or is_playing_line_in: - # playing from line-in/tv. + self._media_artist = track_info.get('artist') + self._media_album_name = None + self._media_title = track_info.get('title') + + if self._media_artist and self._media_title: + # artist and album name are in the data, concatenate + # that do display as artist. + # "Information" field in the sonos pc app + self._media_artist = '{artist} - {title}'.format( + artist=self._media_artist, + title=self._media_title + ) + elif variables: + # "On Now" field in the sonos pc app + current_track_metadata = variables.get('current_track_meta_data') + if current_track_metadata: + self._media_artist = \ + current_track_metadata.radio_show.split(',')[0] + + # For radio streams we set the radio station name as the title. + current_uri_metadata = media_info["CurrentURIMetaData"] + if current_uri_metadata not in ('', 'NOT_IMPLEMENTED', None): + # currently soco does not have an API for this + import soco + current_uri_metadata = soco.xml.XML.fromstring( + soco.utils.really_utf8(current_uri_metadata)) + + md_title = current_uri_metadata.findtext( + './/{http://purl.org/dc/elements/1.1/}title') + + if md_title not in ('', 'NOT_IMPLEMENTED', None): + self._media_title = md_title + + if self._media_artist and self._media_title: + # some radio stations put their name into the artist + # name, e.g.: + # media_title = "Station" + # media_artist = "Station - Artist - Title" + # detect this case and trim from the front of + # media_artist for cosmetics + trim = '{title} - '.format(title=self._media_title) + chars = min(len(self._media_artist), len(trim)) + + if self._media_artist[:chars].upper() == trim[:chars].upper(): + self._media_artist = self._media_artist[chars:] + + # Check if currently playing radio station is in favorites + self._source_name = None + for fav in self._favorites: + if fav.reference.get_uri() == media_info['CurrentURI']: + self._source_name = fav.title - support_previous_track = False - support_next_track = False - support_play = False - support_stop = True - support_pause = False + 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')) - if is_playing_tv: - media_artist = SUPPORT_SOURCE_TV - else: - media_artist = SUPPORT_SOURCE_LINEIN + position_info = self.soco.avTransport.GetPositionInfo( + [('InstanceID', 0), + ('Channel', 'Master')] + ) + rel_time = _timespan_secs(position_info.get("RelTime")) - source_name = media_artist + # player no longer reports position? + update_media_position |= rel_time is None and \ + self._media_position is not None - media_album_name = None - media_title = None - media_image_url = None + # player started reporting position? + update_media_position |= rel_time is not None and \ + self._media_position is None - elif is_radio_stream: - media_image_url = self._format_media_image_url( - media_image_url, - current_media_uri - ) - support_previous_track = False - support_next_track = False - support_play = True - support_stop = True - support_pause = False - - source_name = 'Radio' - # Check if currently playing radio station is in favorites - favc = [fav for fav in self._favorite_sources - if fav['uri'] == current_media_uri] - if len(favc) == 1: - src = favc.pop() - source_name = src['title'] - - # for radio streams we set the radio station name as the - # title. - if media_artist and media_title: - # artist and album name are in the data, concatenate - # that do display as artist. - # "Information" field in the sonos pc app - - media_artist = '{artist} - {title}'.format( - artist=media_artist, - title=media_title - ) - else: - # "On Now" field in the sonos pc app - media_artist = self._media_radio_show - - current_uri_metadata = media_info["CurrentURIMetaData"] - if current_uri_metadata not in ('', 'NOT_IMPLEMENTED', None): - - # currently soco does not have an API for this - import soco - current_uri_metadata = soco.xml.XML.fromstring( - soco.utils.really_utf8(current_uri_metadata)) - - md_title = current_uri_metadata.findtext( - './/{http://purl.org/dc/elements/1.1/}title') - - if md_title not in ('', 'NOT_IMPLEMENTED', None): - media_title = md_title - - if media_artist and media_title: - # some radio stations put their name into the artist - # name, e.g.: - # media_title = "Station" - # media_artist = "Station - Artist - Title" - # detect this case and trim from the front of - # media_artist for cosmetics - str_to_trim = '{title} - '.format( - title=media_title - ) - chars = min(len(media_artist), len(str_to_trim)) - - if media_artist[:chars].upper() == str_to_trim[:chars].upper(): - media_artist = media_artist[chars:] + # position jumped? + if rel_time is not None and self._media_position is not None: + time_diff = utcnow() - self._media_position_updated_at + time_diff = time_diff.total_seconds() - else: - # not a radio stream - media_image_url = self._format_media_image_url( - media_image_url, - track_info['uri'] - ) - support_previous_track = True - support_next_track = True - support_play = True - support_stop = True - support_pause = True - - position_info = self._player.avTransport.GetPositionInfo( - [('InstanceID', 0), - ('Channel', 'Master')] - ) - rel_time = _parse_timespan( - position_info.get("RelTime") - ) + calculated_position = self._media_position + time_diff - # player no longer reports position? - update_media_position = rel_time is None and \ - self._media_position is not None + update_media_position |= abs(calculated_position - rel_time) > 1.5 - # player started reporting position? - update_media_position |= rel_time is not None and \ - self._media_position is None + if update_media_position: + self._media_position = rel_time + self._media_position_updated_at = utcnow() - # position changed? - if rel_time is not None and self._media_position is not None: + self._media_image_url = track_info.get('album_art') - time_diff = utcnow() - self._media_position_updated_at - time_diff = time_diff.total_seconds() + self._media_artist = track_info.get('artist') + self._media_album_name = track_info.get('album') + self._media_title = track_info.get('title') - calculated_position = self._media_position + time_diff + self._source_name = None - update_media_position = \ - abs(calculated_position - rel_time) > 1.5 + def update_volume(self, event=None): + """Update information about currently volume settings.""" + if event: + variables = event.variables - if update_media_position and self.state == STATE_PLAYING: - media_position = rel_time - media_position_updated_at = utcnow() - else: - # don't update media_position (don't want unneeded - # state transitions) - media_position = self._media_position - media_position_updated_at = self._media_position_updated_at - - playlist_position = track_info.get('playlist_position') - if playlist_position in ('', 'NOT_IMPLEMENTED', None): - playlist_position = None - else: - playlist_position = int(playlist_position) + if 'volume' in variables: + self._player_volume = int(variables['volume']['Master']) - playlist_size = media_info.get('NrTracks') - if playlist_size in ('', 'NOT_IMPLEMENTED', None): - playlist_size = None - else: - playlist_size = int(playlist_size) + if 'mute' in variables: + self._player_muted = (variables['mute']['Master'] == '1') - if playlist_position is not None and playlist_size is not None: + if 'night_mode' in variables: + self._night_sound = (variables['night_mode'] == '1') - if playlist_position <= 1: - support_previous_track = False + if 'dialog_level' in variables: + self._speech_enhance = (variables['dialog_level'] == '1') - if playlist_position == playlist_size: - support_next_track = False + 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 - self._media_content_id = track_info.get('title') - self._media_duration = _parse_timespan( - track_info.get('duration') - ) - self._media_position = media_position - self._media_position_updated_at = media_position_updated_at - self._media_image_url = media_image_url - self._media_artist = media_artist - self._media_album_name = media_album_name - self._media_title = media_title - self._current_track_uri = track_info['uri'] - self._current_track_is_radio_stream = is_radio_stream - self._support_previous_track = support_previous_track - self._support_next_track = support_next_track - self._support_play = support_play - self._support_stop = support_stop - self._support_pause = support_pause - self._is_playing_tv = is_playing_tv - self._is_playing_line_in = is_playing_line_in - self._source_name = source_name - self._last_avtransport_event = None - - def _format_media_image_url(self, url, fallback_uri): - if url in ('', 'NOT_IMPLEMENTED', None): - if fallback_uri in ('', 'NOT_IMPLEMENTED', None): - return None - if fallback_uri.find('tts_proxy') > 0: - # If the content is a tts don't try to fetch an image from it. - return None - return 'http://{host}:{port}/getaa?s=1&u={uri}'.format( - host=self._player.ip_address, - port=1400, - uri=urllib.parse.quote(fallback_uri) - ) - return url + def update_groups(self, event=None): + """Process a zone group topology event coming from a player.""" + if event: + self._receives_events = True - def process_sonos_event(self, event): - """Process a service event coming from the speaker.""" - next_track_image_url = None - if event.service == self._player.avTransport: - self._last_avtransport_event = event - - self._media_radio_show = None - if self._current_track_is_radio_stream: - current_track_metadata = event.variables.get( - 'current_track_meta_data' - ) - if current_track_metadata: - self._media_radio_show = \ - current_track_metadata.radio_show.split(',')[0] - - next_track_uri = event.variables.get('next_track_uri') - if next_track_uri: - next_track_image_url = self._format_media_image_url( - None, - next_track_uri - ) - - next_track_metadata = event.variables.get('next_track_meta_data') - if next_track_metadata: - next_track = '{title} - {creator}'.format( - title=next_track_metadata.title, - creator=next_track_metadata.creator - ) - if next_track != self._media_next_title: - self._media_next_title = next_track - else: - self._media_next_title = None + if not hasattr(event, 'zone_player_uui_ds_in_group'): + return - elif event.service == self._player.renderingControl: - if 'volume' in event.variables: - self._player_volume = int( - event.variables['volume'].get('Master') - ) + 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 'mute' in event.variables: - self._player_volume_muted = \ - event.variables['mute'].get('Master') == '1' + 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.schedule_update_ha_state(True) + self._coordinator = None + self._sonos_group = sonos_group + self.schedule_update_ha_state() - if next_track_image_url: - self.preload_media_image_url(next_track_image_url) + 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).""" - return self._player_volume / 100.0 + return self._player_volume / 100 @property def is_volume_muted(self): """Return true if volume is muted.""" - return self._player_volume_muted + return self._player_muted @property - def media_content_id(self): - """Content ID of current playing media.""" - if self._coordinator: - return self._coordinator.media_content_id - - return self._media_content_id + @soco_coordinator + def shuffle(self): + """Shuffling state.""" + return 'SHUFFLE' in self._play_mode @property def media_content_type(self): @@ -783,250 +733,174 @@ def media_content_type(self): return MEDIA_TYPE_MUSIC @property + @soco_coordinator def media_duration(self): """Duration of current playing media in seconds.""" - if self._coordinator: - return self._coordinator.media_duration - return self._media_duration @property + @soco_coordinator def media_position(self): """Position of current playing media in seconds.""" - if self._coordinator: - return self._coordinator.media_position - return self._media_position @property + @soco_coordinator def media_position_updated_at(self): - """When was the position of the current playing media valid. - - Returns value from homeassistant.util.dt.utcnow(). - """ - if self._coordinator: - return self._coordinator.media_position_updated_at - + """When was the position of the current playing media valid.""" return self._media_position_updated_at @property + @soco_coordinator def media_image_url(self): """Image url of current playing media.""" - if self._coordinator: - return self._coordinator.media_image_url - - return self._media_image_url + return self._media_image_url or None @property + @soco_coordinator def media_artist(self): """Artist of current playing media, music track only.""" - if self._coordinator: - return self._coordinator.media_artist - return self._media_artist @property + @soco_coordinator def media_album_name(self): """Album name of current playing media, music track only.""" - if self._coordinator: - return self._coordinator.media_album_name - return self._media_album_name @property + @soco_coordinator def media_title(self): """Title of current playing media.""" - if self._coordinator: - return self._coordinator.media_title - return self._media_title @property + @soco_coordinator + def source(self): + """Name of the current input source.""" + return self._source_name + + @property + @soco_coordinator def supported_features(self): """Flag media player features that are supported.""" - if self._coordinator: - return self._coordinator.supported_features - - supported = SUPPORT_SONOS + return SUPPORT_SONOS - if not self._support_previous_track: - supported = supported ^ SUPPORT_PREVIOUS_TRACK - - if not self._support_next_track: - supported = supported ^ SUPPORT_NEXT_TRACK - - if not self._support_play: - supported = supported ^ SUPPORT_PLAY - - if not self._support_stop: - supported = supported ^ SUPPORT_STOP - - if not self._support_pause: - supported = supported ^ SUPPORT_PAUSE - - return supported - - @soco_error + @soco_error() def volume_up(self): """Volume up media player.""" - self._player.volume += self.volume_increment + self._player.volume += self._volume_increment - @soco_error + @soco_error() def volume_down(self): """Volume down media player.""" - self._player.volume -= self.volume_increment + self._player.volume -= self._volume_increment - @soco_error + @soco_error() def set_volume_level(self, volume): """Set volume level, range 0..1.""" - self._player.volume = str(int(volume * 100)) + self.soco.volume = str(int(volume * 100)) - @soco_error + @soco_error() + @soco_coordinator + def set_shuffle(self, shuffle): + """Enable/Disable shuffle mode.""" + self.soco.play_mode = 'SHUFFLE_NOREPEAT' if shuffle else 'NORMAL' + + @soco_error() def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" - self._player.mute = mute + self.soco.mute = mute - @soco_error + @soco_error() @soco_coordinator def select_source(self, source): """Select input source.""" - if source == SUPPORT_SOURCE_LINEIN: - self._source_name = SUPPORT_SOURCE_LINEIN - self._player.switch_to_line_in() - elif source == SUPPORT_SOURCE_TV: - self._source_name = SUPPORT_SOURCE_TV - self._player.switch_to_tv() + if source == SOURCE_LINEIN: + self.soco.switch_to_line_in() + elif source == SOURCE_TV: + self.soco.switch_to_tv() else: - fav = [fav for fav in self._favorite_sources - if fav['title'] == source] + fav = [fav for fav in self._favorites + if fav.title == source] if len(fav) == 1: src = fav.pop() - self._source_name = src['title'] - - if ('object.container.playlistContainer' in src['meta'] or - 'object.container.album.musicAlbum' in src['meta']): - self._replace_queue_with_playlist(src) - self._player.play_from_queue(0) + uri = src.reference.get_uri() + if _is_radio_uri(uri): + # 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._player.play_uri(src['uri'], src['meta'], - src['title']) - - def _replace_queue_with_playlist(self, src): - """Replace queue with playlist represented by src. - - Playlists can't be played directly with the self._player.play_uri - API as they are actually composed of mulitple URLs. Until soco has - suppport for playing a playlist, we'll need to parse the playlist item - and replace the current queue in order to play it. - """ - import soco - import xml.etree.ElementTree as ET - - root = ET.fromstring(src['meta']) - namespaces = {'item': - 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/', - 'desc': 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/'} - desc = root.find('item:item', namespaces).find('desc:desc', - namespaces).text - - res = [soco.data_structures.DidlResource(uri=src['uri'], - protocol_info="DUMMY")] - didl = soco.data_structures.DidlItem(title="DUMMY", - parent_id="DUMMY", - item_id=src['uri'], - desc=desc, - resources=res) - - self._player.stop() - self._player.clear_queue() - self._player.play_mode = 'NORMAL' - self._player.add_to_queue(didl) + self.soco.clear_queue() + self.soco.add_to_queue(src.reference) + self.soco.play_from_queue(0) @property + @soco_coordinator def source_list(self): """List of available input sources.""" - if self._coordinator: - return self._coordinator.source_list - - model_name = self._speaker_info['model_name'] - sources = [] + sources = [fav.title for fav in self._favorites] - if self._favorite_sources: - for fav in self._favorite_sources: - sources.append(fav['title']) + if 'PLAY:5' in self._model or 'CONNECT' in self._model: + sources += [SOURCE_LINEIN] + elif 'PLAYBAR' in self._model: + sources += [SOURCE_LINEIN, SOURCE_TV] - if 'PLAY:5' in model_name: - sources += [SUPPORT_SOURCE_LINEIN] - elif 'PLAYBAR' in model_name: - sources += [SUPPORT_SOURCE_LINEIN, SUPPORT_SOURCE_TV] return sources - @property - def source(self): - """Name of the current input source.""" - if self._coordinator: - return self._coordinator.source - - return self._source_name + @soco_error() + def turn_on(self): + """Turn the media player on.""" + self.media_play() - @soco_error + @soco_error() def turn_off(self): """Turn off media player.""" - if self._support_stop: - self.media_stop() + self.media_stop() - @soco_error - @soco_filter_upnperror(UPNP_ERRORS_TO_IGNORE) + @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_play(self): """Send play command.""" - self._player.play() + self.soco.play() - @soco_error - @soco_filter_upnperror(UPNP_ERRORS_TO_IGNORE) + @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_stop(self): """Send stop command.""" - self._player.stop() + self.soco.stop() - @soco_error - @soco_filter_upnperror(UPNP_ERRORS_TO_IGNORE) + @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_pause(self): """Send pause command.""" - self._player.pause() + self.soco.pause() - @soco_error + @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_next_track(self): """Send next track command.""" - self._player.next() + self.soco.next() - @soco_error + @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_previous_track(self): """Send next track command.""" - self._player.previous() + self.soco.previous() - @soco_error + @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_seek(self, position): """Send seek command.""" - self._player.seek(str(datetime.timedelta(seconds=int(position)))) + self.soco.seek(str(datetime.timedelta(seconds=int(position)))) - @soco_error + @soco_error() @soco_coordinator def clear_playlist(self): """Clear players playlist.""" - self._player.clear_queue() + self.soco.clear_queue() - @soco_error - def turn_on(self): - """Turn the media player on.""" - if self.support_play: - self.media_play() - - @soco_error + @soco_error() @soco_coordinator def play_media(self, media_type, media_id, **kwargs): """ @@ -1037,51 +911,48 @@ def play_media(self, media_type, media_id, **kwargs): if kwargs.get(ATTR_MEDIA_ENQUEUE): from soco.exceptions import SoCoUPnPException try: - self._player.add_uri_to_queue(media_id) + self.soco.add_uri_to_queue(media_id) except SoCoUPnPException: _LOGGER.error('Error parsing media uri "%s", ' "please check it's a valid media resource " 'supported by Sonos', media_id) else: - self._player.play_uri(media_id) - - @soco_error - def join(self, master): - """Join the player to a group.""" - coord = [device for device in self.hass.data[DATA_SONOS] - if device.entity_id == master] - - if coord and master != self.entity_id: - coord = coord[0] - if coord.soco.group.coordinator != coord.soco: - coord.soco.unjoin() - self._player.join(coord.soco) - self._coordinator = coord - else: - _LOGGER.error("Master not found %s", master) + self.soco.play_uri(media_id) + + @soco_error() + def join(self, slaves): + """Form a group with other players.""" + if self._coordinator: + self.unjoin() + + for slave in slaves: + if slave.unique_id != self.unique_id: + slave.soco.join(self.soco) + # pylint: disable=protected-access + slave._coordinator = self - @soco_error + @soco_error() def unjoin(self): """Unjoin the player from a group.""" - self._player.unjoin() + self.soco.unjoin() self._coordinator = None - @soco_error + @soco_error() def snapshot(self, with_group=True): """Snapshot the player.""" from soco.snapshot import Snapshot - self._soco_snapshot = Snapshot(self._player) + self._soco_snapshot = Snapshot(self.soco) self._soco_snapshot.snapshot() if with_group: - self._snapshot_group = self._player.group + self._snapshot_group = self.soco.group if self._coordinator: self._coordinator.snapshot(False) else: self._snapshot_group = None - @soco_error + @soco_error() def restore(self, with_group=True): """Restore snapshot for the player.""" from soco.exceptions import SoCoException @@ -1095,12 +966,12 @@ def restore(self, with_group=True): # restore groups if with_group and self._snapshot_group: old = self._snapshot_group - actual = self._player.group + actual = self.soco.group ## # Master have not change, update group if old.coordinator == actual.coordinator: - if self._player is not old.coordinator: + if self.soco is not old.coordinator: # restore state of the groups self._coordinator.restore(False) remove = actual.members - old.members @@ -1116,58 +987,76 @@ def restore(self, with_group=True): return ## - # old is allready master, rejoin + # old is already master, rejoin if old.coordinator.group.coordinator == old.coordinator: - self._player.join(old.coordinator) + self.soco.join(old.coordinator) return ## # restore old master, update group old.coordinator.unjoin() - coordinator = _get_entity_from_soco(self.hass, old.coordinator) + coordinator = _get_entity_from_soco_uid( + self.hass, old.coordinator.uid) coordinator.restore(False) for s_dev in list(old.members): if s_dev != old.coordinator: s_dev.join(old.coordinator) - @soco_error + @soco_error() @soco_coordinator def set_sleep_timer(self, sleep_time): """Set the timer on the player.""" - self._player.set_sleep_timer(sleep_time) + self.soco.set_sleep_timer(sleep_time) - @soco_error + @soco_error() @soco_coordinator def clear_sleep_timer(self): """Clear the timer on the player.""" - self._player.set_sleep_timer(None) + self.soco.set_sleep_timer(None) - @soco_error + @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 - a = None - for alarm in alarms.get_alarms(self.soco): + alarm = None + for one_alarm in alarms.get_alarms(self.soco): # pylint: disable=protected-access - if alarm._alarm_id == str(data[ATTR_ALARM_ID]): - a = alarm - if a is None: + if one_alarm._alarm_id == str(data[ATTR_ALARM_ID]): + alarm = one_alarm + if alarm is None: _LOGGER.warning("did not find alarm with id %s", data[ATTR_ALARM_ID]) return if ATTR_TIME in data: - a.start_time = data[ATTR_TIME] + alarm.start_time = data[ATTR_TIME] if ATTR_VOLUME in data: - a.volume = int(data[ATTR_VOLUME] * 100) + alarm.volume = int(data[ATTR_VOLUME] * 100) if ATTR_ENABLED in data: - a.enabled = data[ATTR_ENABLED] + alarm.enabled = data[ATTR_ENABLED] if ATTR_INCLUDE_LINKED_ZONES in data: - a.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] - a.save() + alarm.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] + alarm.save() + + @soco_error() + 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] + + if ATTR_SPEECH_ENHANCE in data and self._speech_enhance is not None: + self.soco.dialog_mode = data[ATTR_SPEECH_ENHANCE] @property def device_state_attributes(self): """Return device specific state attributes.""" - return {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 + + if self._speech_enhance is not None: + attributes[ATTR_SPEECH_ENHANCE] = self._speech_enhance + + return attributes diff --git a/homeassistant/components/media_player/soundtouch.py b/homeassistant/components/media_player/soundtouch.py index c04d3b4d77f07..9c4a0e9fa17e1 100644 --- a/homeassistant/components/media_player/soundtouch.py +++ b/homeassistant/components/media_player/soundtouch.py @@ -6,7 +6,6 @@ """ import logging -from os import path import re import voluptuous as vol @@ -15,8 +14,7 @@ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_VOLUME_SET, SUPPORT_TURN_ON, SUPPORT_PLAY, MediaPlayerDevice, - PLATFORM_SCHEMA) -from homeassistant.config import load_yaml_config_file + DOMAIN, PLATFORM_SCHEMA) from homeassistant.const import (CONF_HOST, CONF_NAME, STATE_OFF, CONF_PORT, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE) @@ -25,7 +23,6 @@ _LOGGER = logging.getLogger(__name__) -DOMAIN = 'media_player' SERVICE_PLAY_EVERYWHERE = 'soundtouch_play_everywhere' SERVICE_CREATE_ZONE = 'soundtouch_create_zone' SERVICE_ADD_ZONE_SLAVE = 'soundtouch_add_zone_slave' @@ -107,9 +104,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass.data[DATA_SOUNDTOUCH].append(soundtouch_device) add_devices([soundtouch_device]) - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - def service_handle(service): """Handle the applying of a service.""" master_device_id = service.data.get('master') @@ -140,19 +134,15 @@ def service_handle(service): hass.services.register(DOMAIN, SERVICE_PLAY_EVERYWHERE, service_handle, - descriptions.get(SERVICE_PLAY_EVERYWHERE), schema=SOUNDTOUCH_PLAY_EVERYWHERE) hass.services.register(DOMAIN, SERVICE_CREATE_ZONE, service_handle, - descriptions.get(SERVICE_CREATE_ZONE), schema=SOUNDTOUCH_CREATE_ZONE_SCHEMA) hass.services.register(DOMAIN, SERVICE_REMOVE_ZONE_SLAVE, service_handle, - descriptions.get(SERVICE_REMOVE_ZONE_SLAVE), schema=SOUNDTOUCH_REMOVE_ZONE_SCHEMA) hass.services.register(DOMAIN, SERVICE_ADD_ZONE_SLAVE, service_handle, - descriptions.get(SERVICE_ADD_ZONE_SLAVE), schema=SOUNDTOUCH_ADD_ZONE_SCHEMA) @@ -306,7 +296,7 @@ def media_album_name(self): def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" - _LOGGER.debug("Starting media with media_id: " + str(media_id)) + _LOGGER.debug("Starting media with media_id: %s", media_id) if re.match(r'http://', str(media_id)): # URL _LOGGER.debug("Playing URL %s", str(media_id)) @@ -317,11 +307,10 @@ def play_media(self, media_type, media_id, **kwargs): preset = next([preset for preset in presets if preset.preset_id == str(media_id)].__iter__(), None) if preset is not None: - _LOGGER.debug("Playing preset: " + preset.name) + _LOGGER.debug("Playing preset: %s", preset.name) self._device.select_preset(preset) else: - _LOGGER.warning( - "Unable to find preset with id " + str(media_id)) + _LOGGER.warning("Unable to find preset with id %s", media_id) def create_zone(self, slaves): """ @@ -333,8 +322,8 @@ def create_zone(self, slaves): if not slaves: _LOGGER.warning("Unable to create zone without slaves") else: - _LOGGER.info( - "Creating zone with master " + str(self.device.config.name)) + _LOGGER.info("Creating zone with master %s", + self.device.config.name) self.device.create_zone([slave.device for slave in slaves]) def remove_zone_slave(self, slaves): @@ -351,8 +340,8 @@ def remove_zone_slave(self, slaves): if not slaves: _LOGGER.warning("Unable to find slaves to remove") else: - _LOGGER.info("Removing slaves from zone with master " + - str(self.device.config.name)) + _LOGGER.info("Removing slaves from zone with master %s", + self.device.config.name) self.device.remove_zone_slave([slave.device for slave in slaves]) def add_zone_slave(self, slaves): @@ -367,7 +356,6 @@ def add_zone_slave(self, slaves): if not slaves: _LOGGER.warning("Unable to find slaves to add") else: - _LOGGER.info( - "Adding slaves to zone with master " + str( - self.device.config.name)) + _LOGGER.info("Adding slaves to zone with master %s", + self.device.config.name) self.device.add_zone_slave([slave.device for slave in slaves]) diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index 734285d918a33..963258f1861df 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -194,7 +194,7 @@ def update(self): 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 a4a15fbce2444..371ad89036414 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -17,10 +17,11 @@ ATTR_MEDIA_ENQUEUE, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_PLAY, MediaPlayerDevice) + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_PLAY, MediaPlayerDevice, + MEDIA_PLAYER_SCHEMA, DOMAIN, SUPPORT_SHUFFLE_SET, SUPPORT_CLEAR_PLAYLIST) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, STATE_IDLE, STATE_OFF, - STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, CONF_PORT) + STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, CONF_PORT, ATTR_COMMAND) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.dt import utcnow @@ -33,7 +34,7 @@ SUPPORT_SQUEEZEBOX = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | \ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ SUPPORT_SEEK | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | \ - SUPPORT_PLAY + SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | SUPPORT_CLEAR_PLAYLIST PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -42,12 +43,39 @@ vol.Optional(CONF_USERNAME): cv.string, }) +SERVICE_CALL_METHOD = 'squeezebox_call_method' + +DATA_SQUEEZEBOX = 'squeezebox' + +KNOWN_SERVERS = 'squeezebox_known_servers' + +ATTR_PARAMETERS = 'parameters' + +SQUEEZEBOX_CALL_METHOD_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ + vol.Required(ATTR_COMMAND): cv.string, + vol.Optional(ATTR_PARAMETERS): + vol.All(cv.ensure_list, vol.Length(min=1), [cv.string]), +}) + +SERVICE_TO_METHOD = { + SERVICE_CALL_METHOD: { + 'method': 'async_call_method', + 'schema': SQUEEZEBOX_CALL_METHOD_SCHEMA}, +} + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the squeezebox platform.""" import socket + known_servers = hass.data.get(KNOWN_SERVERS) + if known_servers is None: + hass.data[KNOWN_SERVERS] = known_servers = set() + + if DATA_SQUEEZEBOX not in hass.data: + hass.data[DATA_SQUEEZEBOX] = [] + username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) @@ -70,12 +98,48 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): "Could not communicate with %s:%d: %s", host, port, error) return False + if ipaddr in known_servers: + return + + known_servers.add(ipaddr) _LOGGER.debug("Creating LMS object for %s", ipaddr) lms = LogitechMediaServer(hass, host, port, username, password) players = yield from lms.create_players() + + hass.data[DATA_SQUEEZEBOX].extend(players) async_add_devices(players) + @asyncio.coroutine + def async_service_handler(service): + """Map services to methods on MediaPlayerDevice.""" + method = SERVICE_TO_METHOD.get(service.service) + if not method: + return + + params = {key: value for key, value in service.data.items() + if key != 'entity_id'} + entity_ids = service.data.get('entity_id') + if entity_ids: + target_players = [player for player in hass.data[DATA_SQUEEZEBOX] + if player.entity_id in entity_ids] + else: + target_players = hass.data[DATA_SQUEEZEBOX] + + update_tasks = [] + for player in target_players: + yield from getattr(player, method['method'])(**params) + update_tasks.append(player.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) + + for service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[service]['schema'] + hass.services.async_register( + DOMAIN, service, async_service_handler, + schema=schema) + return True @@ -166,7 +230,7 @@ def name(self): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._id @property @@ -202,6 +266,8 @@ def async_update(self): if response is False: return + last_media_position = self.media_position + self._status = {} try: @@ -214,7 +280,11 @@ def async_update(self): 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): @@ -305,6 +375,12 @@ def media_album_name(self): if 'album' in self._status: return self._status['album'] + @property + def shuffle(self): + """Boolean if shuffle is enabled.""" + if 'playlist_shuffle' in self._status: + return self._status['playlist_shuffle'] == 1 + @property def supported_features(self): """Flag media player features that are supported.""" @@ -413,5 +489,26 @@ def _play_uri(self, media_id): return self.async_query('playlist', 'play', media_id) def _add_uri_to_playlist(self, media_id): - """Add a items to the existing playlist.""" + """Add an item to the existing playlist.""" return self.async_query('playlist', 'add', media_id) + + def async_set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + return self.async_query('playlist', 'shuffle', int(shuffle)) + + def async_clear_playlist(self): + """Send the media player the command for clear playlist.""" + return self.async_query('playlist', 'clear') + + def async_call_method(self, command, parameters=None): + """ + Call Squeezebox JSON/RPC method. + + Escaped optional parameters are added to the command to form the list + of positional parameters (p0, p1..., pN) passed to JSON/RPC server. + """ + all_params = [command] + if parameters: + for parameter in parameters: + all_params.append(urllib.parse.quote(parameter, safe=':=/?')) + return self.async_query(*all_params) diff --git a/homeassistant/components/media_player/ue_smart_radio.py b/homeassistant/components/media_player/ue_smart_radio.py new file mode 100644 index 0000000000000..2684a8194174a --- /dev/null +++ b/homeassistant/components/media_player/ue_smart_radio.py @@ -0,0 +1,207 @@ +""" +Support for Logitech UE Smart Radios. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.ue_smart_radio/ +""" + +import logging +import voluptuous as vol +import requests + +from homeassistant.components.media_player import ( + MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_PREVIOUS_TRACK, + SUPPORT_NEXT_TRACK, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_MUTE) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, STATE_OFF, STATE_IDLE, STATE_PLAYING, + STATE_PAUSED) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ICON = "mdi:radio" +URL = "http://decibel.logitechmusic.com/jsonrpc.js" + +SUPPORT_UE_SMART_RADIO = SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_TURN_ON | \ + SUPPORT_TURN_OFF | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE + +PLAYBACK_DICT = {"play": STATE_PLAYING, + "pause": STATE_PAUSED, + "stop": STATE_IDLE} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + + +def send_request(payload, session): + """Send request to radio.""" + try: + request = requests.post(URL, + cookies={"sdi_squeezenetwork_session": + session}, + json=payload, timeout=5) + except requests.exceptions.Timeout: + _LOGGER.error("Timed out when sending request") + except requests.exceptions.ConnectionError: + _LOGGER.error("An error occurred while connecting") + else: + return request.json() + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Logitech UE Smart Radio platform.""" + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + session_request = requests.post("https://www.uesmartradio.com/user/login", + data={"email": email, "password": + password}) + session = session_request.cookies["sdi_squeezenetwork_session"] + + player_request = send_request({"params": ["", ["serverstatus"]]}, session) + player_id = player_request["result"]["players_loop"][0]["playerid"] + player_name = player_request["result"]["players_loop"][0]["name"] + + add_devices([UERadioDevice(session, player_id, player_name)]) + + +class UERadioDevice(MediaPlayerDevice): + """Representation of a Logitech UE Smart Radio device.""" + + def __init__(self, session, player_id, player_name): + """Initialize the Logitech UE Smart Radio device.""" + self._session = session + self._player_id = player_id + self._name = player_name + self._state = None + self._volume = 0 + self._last_volume = 0 + self._media_title = None + self._media_artist = None + self._media_artwork_url = None + + def send_command(self, command): + """Send command to radio.""" + send_request({"method": "slim.request", "params": + [self._player_id, command]}, self._session) + + def update(self): + """Get the latest details from the device.""" + request = send_request({ + "method": "slim.request", "params": + [self._player_id, ["status", "-", 1, + "tags:cgABbehldiqtyrSuoKLN"]]}, self._session) + + if request["error"] is not None: + self._state = None + return + + if request["result"]["power"] == 0: + self._state = STATE_OFF + else: + self._state = PLAYBACK_DICT[request["result"]["mode"]] + + media_info = request["result"]["playlist_loop"][0] + + self._volume = request["result"]["mixer volume"] / 100 + self._media_artwork_url = media_info["artwork_url"] + self._media_title = media_info["title"] + if "artist" in media_info: + self._media_artist = media_info["artist"] + else: + self._media_artist = media_info.get("remote_title") + + @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 icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return True if self._volume <= 0 else False + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume + + @property + def supported_features(self): + """Flag of features that are supported.""" + return SUPPORT_UE_SMART_RADIO + + @property + def media_content_type(self): + """Return the media content type.""" + return MEDIA_TYPE_MUSIC + + @property + def media_image_url(self): + """Image URL of current playing media.""" + return self._media_artwork_url + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self._media_artist + + @property + def media_title(self): + """Title of current playing media.""" + return self._media_title + + def turn_on(self): + """Turn on specified media player or all.""" + self.send_command(["power", 1]) + + def turn_off(self): + """Turn off specified media player or all.""" + self.send_command(["power", 0]) + + def media_play(self): + """Send the media player the command for play/pause.""" + self.send_command(["play"]) + + def media_pause(self): + """Send the media player the command for pause.""" + self.send_command(["pause"]) + + def media_stop(self): + """Send the media player the stop command.""" + self.send_command(["stop"]) + + def media_previous_track(self): + """Send the media player the command for prev track.""" + self.send_command(["button", "rew"]) + + def media_next_track(self): + """Send the media player the command for next track.""" + self.send_command(["button", "fwd"]) + + def mute_volume(self, mute): + """Send mute command.""" + if mute: + self._last_volume = self._volume + self.send_command(["mixer", "volume", 0]) + else: + self.send_command(["mixer", "volume", self._last_volume * 100]) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self.send_command(["mixer", "volume", volume * 100]) diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index daf874a31ddc8..03f847ae40c19 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -4,33 +4,35 @@ 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 +import voluptuous as vol + from homeassistant.core import callback from homeassistant.components.media_player import ( - ATTR_APP_ID, ATTR_APP_NAME, ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, - ATTR_MEDIA_ARTIST, ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, - ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_DURATION, ATTR_MEDIA_EPISODE, - ATTR_MEDIA_PLAYLIST, ATTR_MEDIA_SEASON, ATTR_MEDIA_SEEK_POSITION, - ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_TITLE, ATTR_MEDIA_TRACK, - ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_POSITION, ATTR_MEDIA_SHUFFLE, - ATTR_MEDIA_POSITION_UPDATED_AT, DOMAIN, SERVICE_PLAY_MEDIA, + ATTR_APP_ID, ATTR_APP_NAME, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, ATTR_MEDIA_EPISODE, ATTR_MEDIA_PLAYLIST, + ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_SEASON, + ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_TITLE, ATTR_MEDIA_TRACK, ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, DOMAIN, MediaPlayerDevice, PLATFORM_SCHEMA, + SERVICE_CLEAR_PLAYLIST, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, + SUPPORT_CLEAR_PLAYLIST, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, - SUPPORT_SHUFFLE_SET, ATTR_INPUT_SOURCE, SERVICE_SELECT_SOURCE, - SERVICE_CLEAR_PLAYLIST, MediaPlayerDevice) + SUPPORT_VOLUME_STEP) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, CONF_NAME, SERVICE_MEDIA_NEXT_TRACK, - SERVICE_MEDIA_PAUSE, 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, ATTR_SUPPORTED_FEATURES) -from homeassistant.helpers.event import async_track_state_change + ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, CONF_NAME, + CONF_STATE_TEMPLATE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, + 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, STATE_UNAVAILABLE, + SERVICE_MEDIA_STOP) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_call_from_config ATTR_ACTIVE_CHILD = 'active_child' @@ -44,117 +46,78 @@ 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__) +ATTRS_SCHEMA = vol.Schema({cv.slug: cv.string}) +CMD_SCHEMA = vol.Schema({cv.slug: cv.SERVICE_SCHEMA}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_CHILDREN, default=[]): cv.entity_ids, + vol.Optional(CONF_COMMANDS, default={}): CMD_SCHEMA, + vol.Optional(CONF_ATTRS, default={}): + vol.Or(cv.ensure_list(ATTRS_SCHEMA), ATTRS_SCHEMA), + vol.Optional(CONF_STATE_TEMPLATE): cv.template +}, extra=vol.REMOVE_EXTRA) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the universal media players.""" - if not validate_config(config): - return +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the universal media players.""" player = UniversalMediaPlayer( hass, - config[CONF_NAME], - config[CONF_CHILDREN], - config[CONF_COMMANDS], - config[CONF_ATTRS] + config.get(CONF_NAME), + config.get(CONF_CHILDREN), + config.get(CONF_COMMANDS), + config.get(CONF_ATTRS), + config.get(CONF_STATE_TEMPLATE) ) async_add_devices([player]) -def validate_config(config): - """Validate universal media player configuration.""" - del config[CONF_PLATFORM] - - # Validate name - if CONF_NAME not in config: - _LOGGER.error("Universal Media Player configuration requires name") - return False - - validate_children(config) - validate_commands(config) - validate_attributes(config) - - del_keys = [] - for key in config: - if key not in [CONF_NAME, CONF_CHILDREN, CONF_COMMANDS, CONF_ATTRS]: - _LOGGER.warning( - "Universal Media Player (%s) unrecognized parameter %s", - config[CONF_NAME], key) - del_keys.append(key) - for key in del_keys: - del config[key] - - return True - - -def validate_children(config): - """Validate children.""" - if CONF_CHILDREN not in config: - _LOGGER.info( - "No children under Universal Media Player (%s)", config[CONF_NAME]) - config[CONF_CHILDREN] = [] - elif not isinstance(config[CONF_CHILDREN], list): - _LOGGER.warning( - "Universal Media Player (%s) children not list in config. " - "They will be ignored", config[CONF_NAME]) - config[CONF_CHILDREN] = [] - - -def validate_commands(config): - """Validate commands.""" - if CONF_COMMANDS not in config: - config[CONF_COMMANDS] = {} - elif not isinstance(config[CONF_COMMANDS], dict): - _LOGGER.warning( - "Universal Media Player (%s) specified commands not dict in " - "config. They will be ignored", config[CONF_NAME]) - config[CONF_COMMANDS] = {} - - -def validate_attributes(config): - """Validate attributes.""" - if CONF_ATTRS not in config: - config[CONF_ATTRS] = {} - elif not isinstance(config[CONF_ATTRS], dict): - _LOGGER.warning( - "Universal Media Player (%s) specified attributes " - "not dict in config. They will be ignored", config[CONF_NAME]) - config[CONF_ATTRS] = {} - - for key, val in config[CONF_ATTRS].items(): - attr = val.split('|', 1) - if len(attr) == 1: - attr.append(None) - config[CONF_ATTRS][key] = attr - - class UniversalMediaPlayer(MediaPlayerDevice): """Representation of an universal media player.""" - def __init__(self, hass, name, children, commands, attributes): + def __init__(self, hass, name, children, + commands, attributes, state_template=None): """Initialize the Universal media device.""" self.hass = hass self._name = name self._children = children self._cmds = commands - self._attrs = attributes + self._attrs = {} + for key, val in attributes.items(): + attr = val.split('|', 1) + if len(attr) == 1: + attr.append(None) + self._attrs[key] = attr self._child_state = None + self._state_template = state_template + if state_template is not None: + self._state_template.hass = hass + 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. + """ @callback def async_on_dependency_update(*_): """Update ha state when dependencies update.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) - depend = copy(children) - for entity in attributes.values(): + depend = copy(self._children) + for entity in self._attrs.values(): depend.append(entity[0]) + if self._state_template is not None: + for entity in self._state_template.extract_entities(): + depend.append(entity) - async_track_state_change(hass, depend, async_on_dependency_update) + self.hass.helpers.event.async_track_state_change( + list(set(depend)), async_on_dependency_update) def _entity_lkp(self, entity_id, state_attr=None): """Look up an entity state.""" @@ -180,17 +143,17 @@ def _child_attr(self, attr_name): 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) + variables=service_data, blocking=True, + validate_config=False) return active_child = self._child_state @@ -200,7 +163,7 @@ def _async_call_service(self, service_name, service_data=None, 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 @@ -211,6 +174,8 @@ def should_poll(self): @property def master_state(self): """Return the master state for entity or None.""" + if self._state_template is not None: + return self._state_template.async_render() if CONF_STATE in self._attrs: master_state = self._entity_lkp( self._attrs[CONF_STATE][0], self._attrs[CONF_STATE][1]) @@ -232,8 +197,8 @@ def state(self): else master state or off """ master_state = self.master_state # avoid multiple lookups - if master_state == STATE_OFF: - return STATE_OFF + if (master_state == STATE_OFF) or (self._state_template is not None): + return master_state active_child = self._child_state if active_child: @@ -422,12 +387,12 @@ def async_turn_off(self): """ return self._async_call_service(SERVICE_TURN_OFF, allow_override=True) - def async_mute_volume(self, is_volume_muted): + def async_mute_volume(self, mute): """Mute the volume. This method must be run in the event loop and returns a coroutine. """ - data = {ATTR_MEDIA_VOLUME_MUTED: is_volume_muted} + data = {ATTR_MEDIA_VOLUME_MUTED: mute} return self._async_call_service( SERVICE_VOLUME_MUTE, data, allow_override=True) @@ -441,7 +406,7 @@ def async_set_volume_level(self, volume): SERVICE_VOLUME_SET, data, allow_override=True) def async_media_play(self): - """Send play commmand. + """Send play command. This method must be run in the event loop and returns a coroutine. """ @@ -539,8 +504,7 @@ def async_set_shuffle(self, shuffle): 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 4ae8f037a4f95..381482a4839d2 100644 --- a/homeassistant/components/media_player/vizio.py +++ b/homeassistant/components/media_player/vizio.py @@ -1,51 +1,41 @@ """ Vizio SmartCast TV support. -Usually only 2016+ models come with SmartCast capabilities. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.vizio/ """ -import logging from datetime import timedelta +import logging import voluptuous as vol -import homeassistant.util as util from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, - SUPPORT_TURN_ON, - SUPPORT_TURN_OFF, - SUPPORT_SELECT_SOURCE, - SUPPORT_PREVIOUS_TRACK, - SUPPORT_NEXT_TRACK, - SUPPORT_VOLUME_MUTE, - SUPPORT_VOLUME_STEP, - MediaPlayerDevice -) + PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( - STATE_UNKNOWN, - STATE_OFF, - STATE_ON, - CONF_NAME, - CONF_HOST, - CONF_ACCESS_TOKEN -) + CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, + STATE_UNKNOWN) 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__) CONF_SUPPRESS_WARNING = 'suppress_warning' CONF_VOLUME_STEP = 'volume_step' -ICON = 'mdi:television' DEFAULT_NAME = 'Vizio SmartCast' DEFAULT_VOLUME_STEP = 1 -DEVICE_NAME = 'Python Vizio' DEVICE_ID = 'pyvizio' -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) +DEVICE_NAME = 'Python Vizio' + +ICON = 'mdi:television' + MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + SUPPORTED_COMMANDS = SUPPORT_TURN_ON | SUPPORT_TURN_OFF \ | SUPPORT_SELECT_SOURCE \ | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK \ @@ -70,16 +60,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): device = VizioDevice(host, token, name, volume_step) if device.validate_setup() is False: - _LOGGER.error('Failed to setup Vizio TV platform, ' - 'please check if host and API key are correct.') - return False + _LOGGER.error("Failed to setup Vizio TV platform, " + "please check if host and API key are correct") + return if config.get(CONF_SUPPRESS_WARNING): - import requests - from requests.packages.urllib3.exceptions import InsecureRequestWarning - _LOGGER.warning('InsecureRequestWarning is disabled ' - 'because of Vizio platform configuration.') - requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + from requests.packages import urllib3 + _LOGGER.warning("InsecureRequestWarning is disabled " + "because of Vizio platform configuration") + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) add_devices([device], True) @@ -185,5 +174,5 @@ def volume_down(self): self._device.vol_down(num=self._volume_step) def validate_setup(self): - """Validating if host is available and key is correct.""" + """Validate if host is available and key is correct.""" return self._device.get_current_volume() is not None diff --git a/homeassistant/components/media_player/vlc.py b/homeassistant/components/media_player/vlc.py index f77b06054e122..abd8252d813c4 100644 --- a/homeassistant/components/media_player/vlc.py +++ b/homeassistant/components/media_player/vlc.py @@ -9,8 +9,10 @@ import voluptuous as vol from homeassistant.components.media_player import ( - SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC) + SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA, + MEDIA_TYPE_MUSIC) + from homeassistant.const import (CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING) import homeassistant.helpers.config_validation as cv @@ -24,7 +26,7 @@ DEFAULT_NAME = 'Vlc' SUPPORT_VLC = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ - SUPPORT_PLAY_MEDIA | SUPPORT_PLAY + SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_STOP PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME): cv.string, @@ -137,7 +139,7 @@ def set_volume_level(self, volume): self._volume = volume def media_play(self): - """Send play commmand.""" + """Send play command.""" self._vlc.play() self._state = STATE_PLAYING @@ -146,6 +148,11 @@ def media_pause(self): self._vlc.pause() self._state = STATE_PAUSED + def media_stop(self): + """Send stop command.""" + self._vlc.stop() + self._state = STATE_IDLE + def play_media(self, media_type, media_id, **kwargs): """Play media from a URL or file.""" if not media_type == MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/media_player/volumio.py b/homeassistant/components/media_player/volumio.py old mode 100755 new mode 100644 index eda0bc2b32693..11ab16156172d --- a/homeassistant/components/media_player/volumio.py +++ b/homeassistant/components/media_player/volumio.py @@ -3,8 +3,12 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.volumio/ + +Volumio rest API: https://volumio.github.io/docs/API/REST_API.html """ +from datetime import timedelta import logging +import socket import asyncio import aiohttp @@ -13,11 +17,13 @@ from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, - SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC) + SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC, + SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST) from homeassistant.const import ( STATE_PLAYING, STATE_PAUSED, STATE_IDLE, CONF_HOST, CONF_PORT, CONF_NAME) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import Throttle _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -26,12 +32,16 @@ DEFAULT_NAME = 'Volumio' DEFAULT_PORT = 3000 +DATA_VOLUMIO = 'volumio' + TIMEOUT = 10 SUPPORT_VOLUMIO = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \ - SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY + SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY | \ + SUPPORT_VOLUME_STEP | SUPPORT_SELECT_SOURCE | SUPPORT_CLEAR_PLAYLIST +PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=15) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, @@ -43,11 +53,29 @@ @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() + + # 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 - async_add_devices([Volumio(name, host, port, hass)]) + entity = Volumio(name, host, port, hass) + + hass.data[DATA_VOLUMIO][ip_addr] = entity + async_add_devices([entity]) class Volumio(MediaPlayerDevice): @@ -63,6 +91,8 @@ def __init__(self, name, host, port, hass): self._state = {} self.async_update() self._lastvol = self._state.get('volume', 0) + self._playlists = [] + self._currentplaylist = None @asyncio.coroutine def send_volumio_msg(self, method, params=None): @@ -96,6 +126,7 @@ def send_volumio_msg(self, method, params=None): def async_update(self): """Update state.""" resp = yield from self.send_volumio_msg('getState') + yield from self._async_update_playlists() if resp is False: return self._state = resp.copy() @@ -157,7 +188,7 @@ def media_duration(self): def volume_level(self): """Volume level of the media player (0..1).""" volume = self._state.get('volume', None) - if volume is not None: + if volume is not None and volume != "": volume = volume / 100 return volume @@ -171,6 +202,16 @@ def name(self): """Return the name of the device.""" return self._name + @property + def source_list(self): + """Return the list of available input sources.""" + return self._playlists + + @property + def source(self): + """Name of the current input source.""" + return self._currentplaylist + @property def supported_features(self): """Flag of media commands that are supported.""" @@ -199,14 +240,41 @@ def async_set_volume_level(self, volume): return self.send_volumio_msg( 'commands', params={'cmd': 'volume', 'volume': int(volume * 100)}) + def async_volume_up(self): + """Service to send the Volumio the command for volume up.""" + return self.send_volumio_msg( + 'commands', params={'cmd': 'volume', 'volume': 'plus'}) + + def async_volume_down(self): + """Service to send the Volumio the command for volume down.""" + return self.send_volumio_msg( + 'commands', params={'cmd': 'volume', 'volume': 'minus'}) + def async_mute_volume(self, mute): """Send mute command to media player.""" mutecmd = 'mute' if mute else 'unmute' if mute: - # mute is implemenhted as 0 volume, do save last volume level + # mute is implemented as 0 volume, do save last volume level self._lastvol = self._state['volume'] return self.send_volumio_msg( 'commands', params={'cmd': 'volume', 'volume': mutecmd}) return self.send_volumio_msg( 'commands', params={'cmd': 'volume', 'volume': self._lastvol}) + + def async_select_source(self, source): + """Choose a different available playlist and play it.""" + self._currentplaylist = source + return self.send_volumio_msg( + 'commands', params={'cmd': 'playplaylist', 'name': source}) + + def async_clear_playlist(self): + """Clear players playlist.""" + self._currentplaylist = None + return self.send_volumio_msg('commands', + params={'cmd': 'clearQueue'}) + + @Throttle(PLAYLIST_UPDATE_INTERVAL) + async def _async_update_playlists(self, **kwargs): + """Update available Volumio playlists.""" + self._playlists = await self.send_volumio_msg('listplaylists') diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 65a999528c3b4..c3426e454048f 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -4,36 +4,38 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.webostv/ """ -import logging import asyncio from datetime import timedelta +import logging from urllib.parse import urlparse +# pylint: disable=unused-import +from typing import Dict # noqa: F401 + import voluptuous as vol -import homeassistant.util as util from homeassistant.components.media_player import ( - SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PLAY, - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, - SUPPORT_SELECT_SOURCE, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_CHANNEL, - MediaPlayerDevice, PLATFORM_SCHEMA) + MEDIA_TYPE_CHANNEL, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, + SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_MAC, CONF_CUSTOMIZE, CONF_TIMEOUT, STATE_OFF, - STATE_PLAYING, STATE_PAUSED, - STATE_UNKNOWN, CONF_NAME, CONF_FILENAME) + CONF_CUSTOMIZE, CONF_FILENAME, CONF_HOST, CONF_NAME, CONF_TIMEOUT, + STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.script import Script +import homeassistant.util as util -REQUIREMENTS = ['pylgtv==0.1.7', - 'websockets==3.2', - 'wakeonlan==0.2.2'] +REQUIREMENTS = ['pylgtv==0.1.7', 'websockets==3.2'] _CONFIGURING = {} # type: Dict[str, str] _LOGGER = logging.getLogger(__name__) 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' @@ -46,17 +48,16 @@ MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) CUSTOMIZE_SCHEMA = vol.Schema({ - vol.Optional(CONF_SOURCES): - vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_SOURCES): vol.All(cv.ensure_list, [cv.string]), }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_MAC): cv.string, vol.Optional(CONF_CUSTOMIZE, default={}): CUSTOMIZE_SCHEMA, vol.Optional(CONF_FILENAME, default=WEBOSTV_CONFIG_FILE): cv.string, - vol.Optional(CONF_TIMEOUT, default=10): cv.positive_int, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TIMEOUT, default=8): cv.positive_int, }) @@ -76,15 +77,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if host in _CONFIGURING: return - mac = config.get(CONF_MAC) name = config.get(CONF_NAME) customize = config.get(CONF_CUSTOMIZE) timeout = config.get(CONF_TIMEOUT) + turn_on_action = config.get(CONF_ON_ACTION) + config = hass.config.path(config.get(CONF_FILENAME)) - setup_tv(host, mac, name, customize, config, timeout, hass, add_devices) + + setup_tv(host, name, customize, config, timeout, hass, + add_devices, turn_on_action) -def setup_tv(host, mac, name, customize, config, timeout, hass, add_devices): +def setup_tv(host, name, customize, config, timeout, hass, + add_devices, turn_on_action): """Set up a LG WebOS TV based on host parameter.""" from pylgtv import WebOsClient from pylgtv import PyLGTVPairException @@ -108,7 +113,8 @@ def setup_tv(host, mac, name, customize, config, timeout, hass, add_devices): # Not registered, request configuration. _LOGGER.warning("LG webOS TV %s needs to be paired", host) request_configuration( - host, mac, name, customize, config, timeout, hass, add_devices) + host, name, customize, config, timeout, hass, + add_devices, turn_on_action) return # If we came here and configuring this host, mark as done. @@ -117,12 +123,13 @@ def setup_tv(host, mac, name, customize, config, timeout, hass, add_devices): configurator = hass.components.configurator configurator.request_done(request_id) - add_devices([LgWebOSDevice(host, mac, name, customize, config, timeout)], - True) + add_devices([LgWebOSDevice(host, name, customize, config, timeout, + hass, turn_on_action)], True) def request_configuration( - host, mac, name, customize, config, timeout, hass, add_devices): + host, name, customize, config, timeout, hass, + add_devices, turn_on_action): """Request configuration steps from the user.""" configurator = hass.components.configurator @@ -134,9 +141,9 @@ def request_configuration( # pylint: disable=unused-argument def lgtv_configuration_callback(data): - """The actions to do when our configuration callback is called.""" - setup_tv(host, mac, name, customize, config, timeout, hass, - add_devices) + """Handle actions when configuration callback is called.""" + setup_tv(host, name, customize, config, timeout, hass, + add_devices, turn_on_action) _CONFIGURING[host] = configurator.request_config( name, lgtv_configuration_callback, @@ -149,13 +156,12 @@ def lgtv_configuration_callback(data): class LgWebOSDevice(MediaPlayerDevice): """Representation of a LG WebOS TV.""" - def __init__(self, host, mac, name, customize, config, timeout): + def __init__(self, host, name, customize, config, timeout, + hass, on_action): """Initialize the webos device.""" from pylgtv import WebOsClient - from wakeonlan import wol self._client = WebOsClient(host, config, timeout) - self._wol = wol - self._mac = mac + self._on_script = Script(hass, on_action) if on_action else None self._customize = customize self._name = name @@ -169,6 +175,7 @@ def __init__(self, host, mac, name, customize, config, timeout): self._state = STATE_UNKNOWN self._source_list = {} self._app_list = {} + self._channel = None @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) def update(self): @@ -184,10 +191,12 @@ def update(self): self._state = STATE_OFF self._current_source = None self._current_source_id = None + self._channel = None if self._state is not STATE_OFF: self._muted = self._client.get_muted() self._volume = self._client.get_volume() + self._channel = self._client.get_current_channel() self._source_list = {} self._app_list = {} @@ -195,35 +204,32 @@ def update(self): for app in self._client.get_apps(): self._app_list[app['id']] = app - if conf_sources: - if app['id'] == self._current_source_id: - self._current_source = app['title'] - self._source_list[app['title']] = app - elif (app['id'] in conf_sources or - any(word in app['title'] - for word in conf_sources) or - any(word in app['id'] - for word in conf_sources)): - self._source_list[app['title']] = app - else: + if app['id'] == self._current_source_id: self._current_source = app['title'] self._source_list[app['title']] = app + elif (not conf_sources or + app['id'] in conf_sources or + any(word in app['title'] + for word in conf_sources) or + any(word in app['id'] + for word in conf_sources)): + self._source_list[app['title']] = app for source in self._client.get_inputs(): - if conf_sources: - if source['id'] == self._current_source_id: - self._source_list[source['label']] = source - elif (source['label'] in conf_sources or - any(source['label'].find(word) != -1 - for word in conf_sources)): - self._source_list[source['label']] = source - else: + if source['id'] == self._current_source_id: + self._current_source = source['label'] + self._source_list[source['label']] = source + elif (not conf_sources or + source['label'] in conf_sources or + any(source['label'].find(word) != -1 + for word in conf_sources)): self._source_list[source['label']] = source except (OSError, ConnectionClosed, TypeError, asyncio.TimeoutError): self._state = STATE_OFF self._current_source = None self._current_source_id = None + self._channel = None @property def name(self): @@ -260,6 +266,13 @@ def media_content_type(self): """Content type of current playing media.""" return MEDIA_TYPE_CHANNEL + @property + def media_title(self): + """Title of current playing media.""" + if (self._channel is not None) and ('channelName' in self._channel): + return self._channel['channelName'] + return None + @property def media_image_url(self): """Image url of current playing media.""" @@ -273,7 +286,7 @@ def media_image_url(self): @property def supported_features(self): """Flag media player features that are supported.""" - if self._mac: + if self._on_script: return SUPPORT_WEBOSTV | SUPPORT_TURN_ON return SUPPORT_WEBOSTV @@ -289,8 +302,8 @@ def turn_off(self): def turn_on(self): """Turn on the media player.""" - if self._mac: - self._wol.send_magic_packet(self._mac) + if self._on_script: + self._on_script.run() def volume_up(self): """Volume up the media player.""" @@ -319,14 +332,50 @@ def media_play_pause(self): def select_source(self, source): """Select input source.""" - if self._source_list.get(source).get('title'): - self._current_source_id = self._source_list[source]['id'] - self._current_source = self._source_list[source]['title'] - self._client.launch_app(self._source_list[source]['id']) - elif self._source_list.get(source).get('label'): - self._current_source_id = self._source_list[source]['id'] - self._current_source = self._source_list[source]['label'] - self._client.set_input(self._source_list[source]['id']) + source_dict = self._source_list.get(source) + if source_dict is None: + _LOGGER.warning("Source %s not found for %s", source, self.name) + return + self._current_source_id = source_dict['id'] + if source_dict.get('title'): + self._current_source = source_dict['title'] + self._client.launch_app(source_dict['id']) + elif source_dict.get('label'): + 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.""" @@ -342,8 +391,16 @@ def media_pause(self): 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/xiaomi_tv.py b/homeassistant/components/media_player/xiaomi_tv.py new file mode 100644 index 0000000000000..be40bf7d01075 --- /dev/null +++ b/homeassistant/components/media_player/xiaomi_tv.py @@ -0,0 +1,112 @@ +""" +Add support for the Xiaomi TVs. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/xiaomi_tv/ +""" + +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) +from homeassistant.components.media_player import ( + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, MediaPlayerDevice, PLATFORM_SCHEMA, + SUPPORT_VOLUME_STEP) + +REQUIREMENTS = ['pymitv==1.0.0'] + +DEFAULT_NAME = "Xiaomi TV" + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_XIAOMI_TV = SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | \ + SUPPORT_TURN_OFF + +# No host is needed for configuration, however it can be set. +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Xiaomi TV platform.""" + from pymitv import Discover + + # If a hostname is set. Discovery is skipped. + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + + if host is not None: + # Check if there's a valid TV at the IP address. + if not Discover().checkIp(host): + _LOGGER.error( + "Could not find Xiaomi TV with specified IP: %s", host + ) + else: + # Register TV with Home Assistant. + add_devices([XiaomiTV(host, name)]) + else: + # Otherwise, discover TVs on network. + add_devices(XiaomiTV(tv, DEFAULT_NAME) for tv in Discover().scan()) + + +class XiaomiTV(MediaPlayerDevice): + """Represent the Xiaomi TV for Home Assistant.""" + + def __init__(self, ip, name): + """Receive IP address and name to construct class.""" + # Import pymitv library. + from pymitv import TV + + # Initialize the Xiaomi TV. + self._tv = TV(ip) + # Default name value, only to be overridden by user. + self._name = name + self._state = STATE_OFF + + @property + def name(self): + """Return the display name of this TV.""" + return self._name + + @property + def state(self): + """Return _state variable, containing the appropriate constant.""" + return self._state + + @property + def assumed_state(self): + """Indicate that state is assumed.""" + return True + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_XIAOMI_TV + + def turn_off(self): + """ + Instruct the TV to turn sleep. + + This is done instead of turning off, + because the TV won't accept any input when turned off. Thus, the user + would be unable to turn the TV back on, unless it's done manually. + """ + self._tv.sleep() + + self._state = STATE_OFF + + def turn_on(self): + """Wake the TV back up from sleep.""" + self._tv.wake() + + self._state = STATE_ON + + def volume_up(self): + """Increase volume by one.""" + self._tv.volume_up() + + def volume_down(self): + """Decrease volume by one.""" + self._tv.volume_down() diff --git a/homeassistant/components/media_player/yamaha.py b/homeassistant/components/media_player/yamaha.py index f2e64b1fb251d..bb7942a2545ee 100644 --- a/homeassistant/components/media_player/yamaha.py +++ b/homeassistant/components/media_player/yamaha.py @@ -12,13 +12,13 @@ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, SUPPORT_PLAY_MEDIA, SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_PLAY, - MEDIA_TYPE_MUSIC, + MEDIA_TYPE_MUSIC, MEDIA_PLAYER_SCHEMA, DOMAIN, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_NAME, CONF_HOST, STATE_OFF, STATE_ON, - STATE_PLAYING, STATE_IDLE) + STATE_PLAYING, STATE_IDLE, ATTR_ENTITY_ID) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['rxv==0.4.0'] +REQUIREMENTS = ['rxv==0.5.1'] _LOGGER = logging.getLogger(__name__) @@ -27,10 +27,11 @@ CONF_SOURCE_NAMES = 'source_names' CONF_SOURCE_IGNORE = 'source_ignore' +CONF_ZONE_NAMES = 'zone_names' CONF_ZONE_IGNORE = 'zone_ignore' DEFAULT_NAME = 'Yamaha Receiver' -KNOWN = 'yamaha_known_receivers' +DATA_YAMAHA = 'yamaha_known_receivers' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -40,32 +41,42 @@ vol.Optional(CONF_ZONE_IGNORE, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_SOURCE_NAMES, default={}): {cv.string: cv.string}, + vol.Optional(CONF_ZONE_NAMES, default={}): {cv.string: cv.string}, +}) + +SERVICE_ENABLE_OUTPUT = 'yamaha_enable_output' + +ATTR_PORT = 'port' +ATTR_ENABLED = 'enabled' + +ENABLE_OUTPUT_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ + vol.Required(ATTR_PORT): cv.string, + vol.Required(ATTR_ENABLED): cv.boolean }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Yamaha platform.""" import rxv - # keep track of configured receivers so that we don't end up + # Keep track of configured receivers so that we don't end up # discovering a receiver dynamically that we have static config - # for. - if hass.data.get(KNOWN, None) is None: - hass.data[KNOWN] = set() + # for. Map each device from its zone_id to an instance since + # YamahaDevice is not hashable (thus not possible to add to a set). + if hass.data.get(DATA_YAMAHA) is None: + hass.data[DATA_YAMAHA] = {} name = config.get(CONF_NAME) host = config.get(CONF_HOST) source_ignore = config.get(CONF_SOURCE_IGNORE) source_names = config.get(CONF_SOURCE_NAMES) zone_ignore = config.get(CONF_ZONE_IGNORE) + zone_names = config.get(CONF_ZONE_NAMES) if discovery_info is not None: name = discovery_info.get('name') model = discovery_info.get('model_name') ctrl_url = discovery_info.get('control_url') desc_url = discovery_info.get('description_url') - if ctrl_url in hass.data[KNOWN]: - _LOGGER.info("%s already manually configured", ctrl_url) - return receivers = rxv.RXV( ctrl_url, model_name=model, friendly_name=name, unit_desc_url=desc_url).zone_controllers() @@ -80,20 +91,49 @@ def setup_platform(hass, config, add_devices, discovery_info=None): ctrl_url = "http://{}:80/YamahaRemoteControl/ctrl".format(host) receivers = rxv.RXV(ctrl_url, name).zone_controllers() + devices = [] for receiver in receivers: - if receiver.zone not in zone_ignore: - hass.data[KNOWN].add(receiver.ctrl_url) - add_devices([ - YamahaDevice(name, receiver, source_ignore, source_names) - ], True) + if receiver.zone in zone_ignore: + continue + + device = YamahaDevice(name, receiver, source_ignore, + source_names, zone_names) + + # Only add device if it's not already added + if device.zone_id not in hass.data[DATA_YAMAHA]: + hass.data[DATA_YAMAHA][device.zone_id] = device + devices.append(device) + else: + _LOGGER.debug('Ignoring duplicate receiver %s', name) + + def service_handler(service): + """Handle for services.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + + devices = [device for device in hass.data[DATA_YAMAHA].values() + if not entity_ids or device.entity_id in entity_ids] + + for device in devices: + port = service.data[ATTR_PORT] + enabled = service.data[ATTR_ENABLED] + + device.enable_output(port, enabled) + device.schedule_update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_ENABLE_OUTPUT, service_handler, + schema=ENABLE_OUTPUT_SCHEMA) + + add_devices(devices) class YamahaDevice(MediaPlayerDevice): """Representation of a Yamaha device.""" - def __init__(self, name, receiver, source_ignore, source_names): + def __init__(self, name, receiver, source_ignore, + source_names, zone_names): """Initialize the Yamaha Receiver.""" - self._receiver = receiver + self.receiver = receiver self._muted = False self._volume = 0 self._pwstate = STATE_OFF @@ -101,6 +141,7 @@ def __init__(self, name, receiver, source_ignore, source_names): self._source_list = None self._source_ignore = source_ignore or [] self._source_names = source_names or {} + self._zone_names = zone_names or {} self._reverse_mapping = None self._playback_support = None self._is_playback_supported = False @@ -110,8 +151,8 @@ def __init__(self, name, receiver, source_ignore, source_names): def update(self): """Get the latest details from the device.""" - self._play_status = self._receiver.play_status() - if self._receiver.on: + self._play_status = self.receiver.play_status() + if self.receiver.on: if self._play_status is None: self._pwstate = STATE_ON elif self._play_status.playing: @@ -121,17 +162,17 @@ def update(self): else: self._pwstate = STATE_OFF - self._muted = self._receiver.mute - self._volume = (self._receiver.volume / 100) + 1 + self._muted = self.receiver.mute + self._volume = (self.receiver.volume / 100) + 1 if self.source_list is None: self.build_source_list() - current_source = self._receiver.input + current_source = self.receiver.input self._current_source = self._source_names.get( current_source, current_source) - self._playback_support = self._receiver.get_playback_support() - self._is_playback_supported = self._receiver.is_playback_supported( + self._playback_support = self.receiver.get_playback_support() + self._is_playback_supported = self.receiver.is_playback_supported( self._current_source) def build_source_list(self): @@ -141,16 +182,17 @@ def build_source_list(self): self._source_list = sorted( self._source_names.get(source, source) for source in - self._receiver.inputs() + self.receiver.inputs() if source not in self._source_ignore) @property def name(self): """Return the name of the device.""" name = self._name - if self._zone != "Main_Zone": + zone_name = self._zone_names.get(self._zone, self._zone) + if zone_name != "Main_Zone": # Zone will be one of Main_Zone, Zone_2, Zone_3 - name += " " + self._zone.replace('_', ' ') + name += " " + zone_name.replace('_', ' ') return name @property @@ -178,6 +220,11 @@ def source_list(self): """List of available input sources.""" return self._source_list + @property + def zone_id(self): + """Return a zone_id to ensure 1 media player per zone.""" + return '{0}:{1}'.format(self.receiver.ctrl_url, self._zone) + @property def supported_features(self): """Flag media player features that are supported.""" @@ -196,42 +243,42 @@ def supported_features(self): def turn_off(self): """Turn off media player.""" - self._receiver.on = False + self.receiver.on = False def set_volume_level(self, volume): """Set volume level, range 0..1.""" receiver_vol = 100 - (volume * 100) negative_receiver_vol = -receiver_vol - self._receiver.volume = negative_receiver_vol + self.receiver.volume = negative_receiver_vol def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" - self._receiver.mute = mute + self.receiver.mute = mute def turn_on(self): """Turn the media player on.""" - self._receiver.on = True - self._volume = (self._receiver.volume / 100) + 1 + self.receiver.on = True + self._volume = (self.receiver.volume / 100) + 1 def media_play(self): - """Send play commmand.""" - self._call_playback_function(self._receiver.play, "play") + """Send play command.""" + self._call_playback_function(self.receiver.play, "play") def media_pause(self): """Send pause command.""" - self._call_playback_function(self._receiver.pause, "pause") + self._call_playback_function(self.receiver.pause, "pause") def media_stop(self): """Send stop command.""" - self._call_playback_function(self._receiver.stop, "stop") + self._call_playback_function(self.receiver.stop, "stop") def media_previous_track(self): """Send previous track command.""" - self._call_playback_function(self._receiver.previous, "previous track") + self._call_playback_function(self.receiver.previous, "previous track") def media_next_track(self): """Send next track command.""" - self._call_playback_function(self._receiver.next, "next track") + self._call_playback_function(self.receiver.next, "next track") def _call_playback_function(self, function, function_text): import rxv @@ -243,7 +290,7 @@ def _call_playback_function(self, function, function_text): def select_source(self, source): """Select input source.""" - self._receiver.input = self._reverse_mapping.get(source, source) + self.receiver.input = self._reverse_mapping.get(source, source) def play_media(self, media_type, media_id, **kwargs): """Play media from an ID. @@ -252,9 +299,27 @@ def play_media(self, media_type, media_id, **kwargs): Yamaha to direct play certain kinds of media. media_type is treated as the input type that we are setting, and media id is specific to it. + + For the NET RADIO mediatype the format for ``media_id`` is a + "path" in your vtuner hierarchy. For instance: + ``Bookmarks>Internet>Radio Paradise``. The separators are + ``>`` and the parts of this are navigated by name behind the + scenes. There is a looping construct built into the yamaha + library to do this with a fallback timeout if the vtuner + service is unresponsive. + + NOTE: this might take a while, because the only API interface + for setting the net radio station emulates button pressing and + navigating through the net radio menu hierarchy. And each sub + menu must be fetched by the receiver from the vtuner service. + """ if media_type == "NET RADIO": - self._receiver.net_radio(media_id) + self.receiver.net_radio(media_id) + + def enable_output(self, port, enabled): + """Enable or disable an output port..""" + self.receiver.enable_output(port, enabled) @property def media_artist(self): diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py index 88d17b4d6274a..b42a5ae474c2e 100644 --- a/homeassistant/components/media_player/yamaha_musiccast.py +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -2,7 +2,6 @@ media_player: - platform: yamaha_musiccast - name: "Living Room" host: 192.168.xxx.xx port: 5005 @@ -11,10 +10,11 @@ import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, - STATE_UNKNOWN, STATE_ON + CONF_HOST, CONF_PORT, + STATE_UNKNOWN, STATE_ON, STATE_PLAYING, STATE_PAUSED, STATE_IDLE ) from homeassistant.components.media_player import ( MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, @@ -33,53 +33,97 @@ SUPPORT_SELECT_SOURCE ) -REQUIREMENTS = ['pymusiccast==0.1.0'] +KNOWN_HOSTS_KEY = 'data_yamaha_musiccast' +INTERVAL_SECONDS = 'interval_seconds' + +REQUIREMENTS = ['pymusiccast==0.1.6'] -DEFAULT_NAME = "Yamaha Receiver" DEFAULT_PORT = 5005 +DEFAULT_INTERVAL = 480 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.positive_int, + vol.Optional(INTERVAL_SECONDS, default=DEFAULT_INTERVAL): cv.positive_int, }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Yamaha MusicCast platform.""" + import socket import pymusiccast - name = config.get(CONF_NAME) + known_hosts = hass.data.get(KNOWN_HOSTS_KEY) + if known_hosts is None: + known_hosts = hass.data[KNOWN_HOSTS_KEY] = [] + _LOGGER.debug("known_hosts: %s", known_hosts) + host = config.get(CONF_HOST) port = config.get(CONF_PORT) - - receiver = pymusiccast.McDevice(host, udp_port=port) - _LOGGER.debug("receiver: %s / Port: %d", receiver, port) - - add_devices([YamahaDevice(receiver, name)], True) + interval = config.get(INTERVAL_SECONDS) + + # Get IP of host to prevent duplicates + try: + ipaddr = socket.gethostbyname(host) + except (OSError) as error: + _LOGGER.error( + "Could not communicate with %s:%d: %s", host, port, error) + return + + if [item for item in known_hosts if item[0] == ipaddr]: + _LOGGER.warning("Host %s:%d already registered.", host, port) + return + + if [item for item in known_hosts if item[1] == port]: + _LOGGER.warning("Port %s:%d already registered.", host, port) + return + + reg_host = (ipaddr, port) + known_hosts.append(reg_host) + + try: + receiver = pymusiccast.McDevice( + ipaddr, udp_port=port, mc_interval=interval) + except pymusiccast.exceptions.YMCInitError as err: + _LOGGER.error(err) + receiver = None + + if receiver: + for zone in receiver.zones: + _LOGGER.debug( + "receiver: %s / Port: %d / Zone: %s", + receiver, port, zone) + add_devices( + [YamahaDevice(receiver, receiver.zones[zone])], + True) + else: + known_hosts.remove(reg_host) class YamahaDevice(MediaPlayerDevice): """Representation of a Yamaha MusicCast device.""" - def __init__(self, receiver, name): + def __init__(self, recv, zone): """Initialize the Yamaha MusicCast device.""" - self._receiver = receiver - self._name = name - self.power = STATE_UNKNOWN - self.volume = 0 - self.volume_max = 0 - self.mute = False + self._recv = recv + self._name = recv.name self._source = None self._source_list = [] - self.status = STATE_UNKNOWN + self._zone = zone + self.mute = False self.media_status = None - self._receiver.set_yamaha_device(self) + self.media_status_received = None + self.power = STATE_UNKNOWN + self.status = STATE_UNKNOWN + self.volume = 0 + self.volume_max = 0 + self._recv.set_yamaha_device(self) + self._zone.set_yamaha_device(self) @property def name(self): """Return the name of the device.""" - return self._name + return "{} ({})".format(self._name, self._zone.zone_id) @property def state(self): @@ -160,74 +204,88 @@ def media_title(self): """Title of current playing media.""" return self.media_status.media_title if self.media_status else None + @property + def media_position(self): + """Position of current playing media in seconds.""" + if self.media_status and self.state in \ + [STATE_PLAYING, STATE_PAUSED, STATE_IDLE]: + return self.media_status.media_position + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self.media_status_received if self.media_status else None + def update(self): """Get the latest details from the device.""" _LOGGER.debug("update: %s", self.entity_id) + self._recv.update_status() + self._zone.update_status() - # call from constructor setup_platform() - if not self.entity_id: - _LOGGER.debug("First run") - self._receiver.update_status(push=False) - # call from regular polling - else: - # update_status_timer was set before - if self._receiver.update_status_timer: - _LOGGER.debug( - "is_alive: %s", - self._receiver.update_status_timer.is_alive()) - # e.g. computer was suspended, while hass was running - if not self._receiver.update_status_timer.is_alive(): - _LOGGER.debug("Reinitializing") - self._receiver.update_status() + def update_hass(self): + """Push updates to HASS.""" + if self.entity_id: + _LOGGER.debug("update_hass: pushing updates") + self.schedule_update_ha_state() + return True def turn_on(self): """Turn on specified media player or all.""" _LOGGER.debug("Turn device: on") - self._receiver.set_power(True) + self._zone.set_power(True) def turn_off(self): """Turn off specified media player or all.""" _LOGGER.debug("Turn device: off") - self._receiver.set_power(False) + self._zone.set_power(False) def media_play(self): """Send the media player the command for play/pause.""" _LOGGER.debug("Play") - self._receiver.set_playback("play") + self._recv.set_playback("play") def media_pause(self): """Send the media player the command for pause.""" _LOGGER.debug("Pause") - self._receiver.set_playback("pause") + self._recv.set_playback("pause") def media_stop(self): """Send the media player the stop command.""" _LOGGER.debug("Stop") - self._receiver.set_playback("stop") + self._recv.set_playback("stop") def media_previous_track(self): """Send the media player the command for prev track.""" _LOGGER.debug("Previous") - self._receiver.set_playback("previous") + self._recv.set_playback("previous") def media_next_track(self): """Send the media player the command for next track.""" _LOGGER.debug("Next") - self._receiver.set_playback("next") + self._recv.set_playback("next") def mute_volume(self, mute): """Send mute command.""" _LOGGER.debug("Mute volume: %s", mute) - self._receiver.set_mute(mute) + self._zone.set_mute(mute) def set_volume_level(self, volume): """Set volume level, range 0..1.""" _LOGGER.debug("Volume level: %.2f / %d", volume, volume * self.volume_max) - self._receiver.set_volume(volume * self.volume_max) + self._zone.set_volume(volume * self.volume_max) def select_source(self, source): """Send the media player the command to select input source.""" _LOGGER.debug("select_source: %s", source) self.status = STATE_UNKNOWN - self._receiver.set_input(source) + self._zone.set_input(source) + + def new_media_status(self, status): + """Handle updates of the media status.""" + _LOGGER.debug("new media_status arrived") + self.media_status = status + self.media_status_received = dt_util.utcnow() diff --git a/homeassistant/components/media_player/ziggo_mediabox_xl.py b/homeassistant/components/media_player/ziggo_mediabox_xl.py new file mode 100644 index 0000000000000..1886cd751ea87 --- /dev/null +++ b/homeassistant/components/media_player/ziggo_mediabox_xl.py @@ -0,0 +1,174 @@ +""" +Support for interface with a Ziggo Mediabox XL. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.ziggo_mediabox_xl/ +""" +import logging +import socket + +import voluptuous as vol + +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, MediaPlayerDevice, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, + SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, + SUPPORT_PLAY, SUPPORT_PAUSE) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['ziggo-mediabox-xl==1.0.0'] + +_LOGGER = logging.getLogger(__name__) + +DATA_KNOWN_DEVICES = 'ziggo_mediabox_xl_known_devices' + +SUPPORT_ZIGGO = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ + SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Ziggo Mediabox XL platform.""" + from ziggo_mediabox_xl import ZiggoMediaboxXL + + hass.data[DATA_KNOWN_DEVICES] = known_devices = set() + + # Is this a manual configuration? + if config.get(CONF_HOST) is not None: + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + elif discovery_info is not None: + host = discovery_info.get('host') + name = discovery_info.get('name') + else: + _LOGGER.error("Cannot determine device") + return + + # Only add a device once, so discovered devices do not override manual + # config. + hosts = [] + ip_addr = socket.gethostbyname(host) + if ip_addr not in known_devices: + try: + mediabox = ZiggoMediaboxXL(ip_addr) + if mediabox.test_connection(): + hosts.append(ZiggoMediaboxXLDevice(mediabox, host, name)) + known_devices.add(ip_addr) + else: + _LOGGER.error("Can't connect to %s", host) + except socket.error as error: + _LOGGER.error("Can't connect to %s: %s", host, error) + else: + _LOGGER.info("Ignoring duplicate Ziggo Mediabox XL %s", host) + add_devices(hosts, True) + + +class ZiggoMediaboxXLDevice(MediaPlayerDevice): + """Representation of a Ziggo Mediabox XL Device.""" + + def __init__(self, mediabox, host, name): + """Initialize the device.""" + # Generate a configuration for the Samsung library + self._mediabox = mediabox + self._host = host + self._name = name + self._state = None + + def update(self): + """Retrieve the state of the device.""" + try: + if self._mediabox.turned_on(): + if self._state != STATE_PAUSED: + self._state = STATE_PLAYING + else: + self._state = STATE_OFF + except socket.error: + _LOGGER.error("Couldn't fetch state from %s", self._host) + + def send_keys(self, keys): + """Send keys to the device and handle exceptions.""" + try: + self._mediabox.send_keys(keys) + except socket.error: + _LOGGER.error("Couldn't send keys to %s", self._host) + + @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 source_list(self): + """List of available sources (channels).""" + return [self._mediabox.channels()[c] + for c in sorted(self._mediabox.channels().keys())] + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_ZIGGO + + def turn_on(self): + """Turn the media player on.""" + self.send_keys(['POWER']) + self._state = STATE_ON + + def turn_off(self): + """Turn off media player.""" + self.send_keys(['POWER']) + self._state = STATE_OFF + + def media_play(self): + """Send play command.""" + self.send_keys(['PLAY']) + self._state = STATE_PLAYING + + def media_pause(self): + """Send pause command.""" + self.send_keys(['PAUSE']) + self._state = STATE_PAUSED + + def media_play_pause(self): + """Simulate play pause media player.""" + self.send_keys(['PAUSE']) + if self._state == STATE_PAUSED: + self._state = STATE_PLAYING + else: + self._state = STATE_PAUSED + + def media_next_track(self): + """Channel up.""" + self.send_keys(['CHAN_UP']) + self._state = STATE_PLAYING + + def media_previous_track(self): + """Channel down.""" + self.send_keys(['CHAN_DOWN']) + self._state = STATE_PLAYING + + def select_source(self, source): + """Select the channel.""" + if str(source).isdigit(): + digits = str(source) + else: + digits = next(( + key for key, value in self._mediabox.channels().items() + if value == source), None) + if digits is None: + return + + self.send_keys(['NUM_{}'.format(digit) + for digit in str(digits)]) + self._state = STATE_PLAYING diff --git a/homeassistant/components/melissa.py b/homeassistant/components/melissa.py new file mode 100644 index 0000000000000..f5a757dbcf37e --- /dev/null +++ b/homeassistant/components/melissa.py @@ -0,0 +1,44 @@ +""" +Support for Melissa climate. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/melissa/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import load_platform + +REQUIREMENTS = ["py-melissa-climate==1.0.6"] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "melissa" +DATA_MELISSA = 'MELISSA' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Melissa Climate component.""" + import melissa + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + + api = melissa.Melissa(username=username, password=password) + hass.data[DATA_MELISSA] = api + + load_platform(hass, 'sensor', DOMAIN, {}) + load_platform(hass, 'climate', DOMAIN, {}) + return True diff --git a/homeassistant/components/microsoft_face.py b/homeassistant/components/microsoft_face.py index 49d79ccaea00c..847f4131f4334 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/ @@ -7,46 +7,40 @@ import asyncio import json import logging -import os import aiohttp from aiohttp.hdrs import CONTENT_TYPE import async_timeout import voluptuous as vol -from homeassistant.const import CONF_API_KEY, CONF_TIMEOUT -from homeassistant.config import load_yaml_config_file +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'] +ATTR_CAMERA_ENTITY = 'camera_entity' +ATTR_GROUP = 'group' +ATTR_PERSON = 'person' -FACE_API_URL = "api.cognitive.microsoft.com/face/v1.0/{0}" +CONF_AZURE_REGION = 'azure_region' DATA_MICROSOFT_FACE = 'microsoft_face' +DEFAULT_TIMEOUT = 10 +DEPENDENCIES = ['camera'] +DOMAIN = 'microsoft_face' -CONF_AZURE_REGION = 'azure_region' +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({ @@ -114,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, @@ -133,10 +127,6 @@ def async_setup(hass, config): hass.data[DATA_MICROSOFT_FACE] = face - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine def async_create_group(service): """Create a new person group.""" @@ -155,7 +145,6 @@ def async_create_group(service): hass.services.async_register( DOMAIN, SERVICE_CREATE_GROUP, async_create_group, - descriptions[DOMAIN].get(SERVICE_CREATE_GROUP), schema=SCHEMA_GROUP_SERVICE) @asyncio.coroutine @@ -168,13 +157,12 @@ def async_delete_group(service): face.store.pop(g_id) entity = entities.pop(g_id) - yield from entity.async_remove() + hass.states.async_remove(entity.entity_id) except HomeAssistantError as err: _LOGGER.error("Can't delete group '%s' with error: %s", g_id, err) hass.services.async_register( DOMAIN, SERVICE_DELETE_GROUP, async_delete_group, - descriptions[DOMAIN].get(SERVICE_DELETE_GROUP), schema=SCHEMA_GROUP_SERVICE) @asyncio.coroutine @@ -190,7 +178,6 @@ def async_train_group(service): hass.services.async_register( DOMAIN, SERVICE_TRAIN_GROUP, async_train_group, - descriptions[DOMAIN].get(SERVICE_TRAIN_GROUP), schema=SCHEMA_TRAIN_SERVICE) @asyncio.coroutine @@ -211,7 +198,6 @@ def async_create_person(service): hass.services.async_register( DOMAIN, SERVICE_CREATE_PERSON, async_create_person, - descriptions[DOMAIN].get(SERVICE_CREATE_PERSON), schema=SCHEMA_PERSON_SERVICE) @asyncio.coroutine @@ -232,7 +218,6 @@ def async_delete_person(service): hass.services.async_register( DOMAIN, SERVICE_DELETE_PERSON, async_delete_person, - descriptions[DOMAIN].get(SERVICE_DELETE_PERSON), schema=SCHEMA_PERSON_SERVICE) @asyncio.coroutine @@ -242,7 +227,7 @@ def async_face_person(service): 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) @@ -251,7 +236,7 @@ def async_face_person(service): 'post', "persongroups/{0}/persons/{1}/persistedFaces".format( g_id, p_id), - image, + image.content, binary=True ) except HomeAssistantError as err: @@ -259,7 +244,6 @@ def async_face_person(service): hass.services.async_register( DOMAIN, SERVICE_FACE_PERSON, async_face_person, - descriptions[DOMAIN].get(SERVICE_FACE_PERSON), schema=SCHEMA_FACE_SERVICE) return True @@ -349,7 +333,7 @@ def update_store(self): @asyncio.coroutine def call_api(self, method, function, data=None, binary=False, params=None): - """Make a api call.""" + """Make an api call.""" headers = {"Ocp-Apim-Subscription-Key": self._api_key} url = self._server_url.format(function) diff --git a/homeassistant/components/mochad.py b/homeassistant/components/mochad.py index 165c43f488f09..9f53f84e020a7 100644 --- a/homeassistant/components/mochad.py +++ b/homeassistant/components/mochad.py @@ -5,6 +5,7 @@ https://home-assistant.io/components/mochad/ """ import logging +import threading import voluptuous as vol @@ -13,7 +14,7 @@ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from homeassistant.const import (CONF_HOST, CONF_PORT) -REQUIREMENTS = ['pymochad==0.1.1'] +REQUIREMENTS = ['pymochad==0.2.0'] _LOGGER = logging.getLogger(__name__) @@ -23,6 +24,8 @@ DOMAIN = 'mochad' +REQ_LOCK = threading.Lock() + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_HOST, default='localhost'): cv.string, diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index 9075eab2cdd6b..a928c0d3aca03 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -6,15 +6,13 @@ """ import logging import threading -import os import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - CONF_HOST, CONF_METHOD, CONF_PORT, ATTR_STATE) + CONF_HOST, CONF_METHOD, CONF_PORT, CONF_TYPE, CONF_TIMEOUT, ATTR_STATE) DOMAIN = 'modbus' @@ -24,7 +22,6 @@ CONF_BAUDRATE = 'baudrate' CONF_BYTESIZE = 'bytesize' CONF_STOPBITS = 'stopbits' -CONF_TYPE = 'type' CONF_PARITY = 'parity' SERIAL_SCHEMA = { @@ -35,12 +32,14 @@ vol.Required(CONF_PARITY): vol.Any('E', 'O', 'N'), vol.Required(CONF_STOPBITS): vol.Any(1, 2), vol.Required(CONF_TYPE): 'serial', + vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, } ETHERNET_SCHEMA = { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.positive_int, - vol.Required(CONF_TYPE): vol.Any('tcp', 'udp'), + vol.Required(CONF_TYPE): vol.Any('tcp', 'udp', 'rtuovertcp'), + vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, } @@ -89,15 +88,25 @@ def setup(hass, config): baudrate=config[DOMAIN][CONF_BAUDRATE], stopbits=config[DOMAIN][CONF_STOPBITS], bytesize=config[DOMAIN][CONF_BYTESIZE], - parity=config[DOMAIN][CONF_PARITY]) + parity=config[DOMAIN][CONF_PARITY], + timeout=config[DOMAIN][CONF_TIMEOUT]) + elif client_type == 'rtuovertcp': + from pymodbus.client.sync import ModbusTcpClient as ModbusClient + from pymodbus.transaction import ModbusRtuFramer as ModbusFramer + client = ModbusClient(host=config[DOMAIN][CONF_HOST], + port=config[DOMAIN][CONF_PORT], + framer=ModbusFramer, + timeout=config[DOMAIN][CONF_TIMEOUT]) elif client_type == 'tcp': from pymodbus.client.sync import ModbusTcpClient as ModbusClient client = ModbusClient(host=config[DOMAIN][CONF_HOST], - port=config[DOMAIN][CONF_PORT]) + port=config[DOMAIN][CONF_PORT], + timeout=config[DOMAIN][CONF_TIMEOUT]) elif client_type == 'udp': from pymodbus.client.sync import ModbusUdpClient as ModbusClient client = ModbusClient(host=config[DOMAIN][CONF_HOST], - port=config[DOMAIN][CONF_PORT]) + port=config[DOMAIN][CONF_PORT], + timeout=config[DOMAIN][CONF_TIMEOUT]) else: return False @@ -113,17 +122,12 @@ def start_modbus(event): HUB.connect() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus) - descriptions = load_yaml_config_file(os.path.join( - os.path.dirname(__file__), 'services.yaml')).get(DOMAIN) - # Register services for modbus hass.services.register( DOMAIN, SERVICE_WRITE_REGISTER, write_register, - descriptions.get(SERVICE_WRITE_REGISTER), schema=SERVICE_WRITE_REGISTER_SCHEMA) hass.services.register( DOMAIN, SERVICE_WRITE_COIL, write_coil, - descriptions.get(SERVICE_WRITE_COIL), schema=SERVICE_WRITE_COIL_SCHEMA) def write_register(service): diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 929ae0fc45575..55d99a0817e1f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -5,6 +5,9 @@ https://home-assistant.io/components/mqtt/ """ import asyncio +from itertools import groupby +from typing import Optional, Any, Union, Callable, List, cast # noqa: F401 +from operator import attrgetter import logging import os import socket @@ -12,25 +15,26 @@ import ssl import re import requests.certs +import attr import voluptuous as vol -from homeassistant.core import callback +from homeassistant.helpers.typing import HomeAssistantType, ConfigType, \ + ServiceDataType +from homeassistant.core import callback, Event, ServiceCall from homeassistant.setup import async_prepare_setup_platform -from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.helpers import template, config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, dispatcher_send) -from homeassistant.util.async import ( +from homeassistant.helpers.entity import Entity +from homeassistant.util.async_ import ( run_coroutine_threadsafe, run_callback_threadsafe) from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_VALUE_TEMPLATE, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, CONF_PAYLOAD) from homeassistant.components.mqtt.server import HBMQTT_CONFIG_SCHEMA -REQUIREMENTS = ['paho-mqtt==1.3.0'] +REQUIREMENTS = ['paho-mqtt==1.3.1'] _LOGGER = logging.getLogger(__name__) @@ -39,7 +43,6 @@ DATA_MQTT = 'mqtt' SERVICE_PUBLISH = 'publish' -SIGNAL_MQTT_MESSAGE_RECEIVED = 'mqtt_message_received' CONF_EMBEDDED = 'embedded' CONF_BROKER = 'broker' @@ -59,6 +62,8 @@ CONF_STATE_TOPIC = 'state_topic' CONF_COMMAND_TOPIC = 'command_topic' CONF_AVAILABILITY_TOPIC = 'availability_topic' +CONF_PAYLOAD_AVAILABLE = 'payload_available' +CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' CONF_QOS = 'qos' CONF_RETAIN = 'retain' @@ -73,6 +78,8 @@ DEFAULT_DISCOVERY = False DEFAULT_DISCOVERY_PREFIX = 'homeassistant' DEFAULT_TLS_PROTOCOL = 'auto' +DEFAULT_PAYLOAD_AVAILABLE = 'online' +DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline' ATTR_TOPIC = 'topic' ATTR_PAYLOAD = 'payload' @@ -83,22 +90,52 @@ MAX_RECONNECT_WAIT = 300 # seconds -def valid_subscribe_topic(value, invalid_chars='\0'): - """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') - - -def valid_publish_topic(value): + 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): - """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])) @@ -136,8 +173,10 @@ def valid_discovery_topic(value): 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) @@ -145,6 +184,14 @@ def valid_discovery_topic(value): vol.Optional(CONF_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA, } +MQTT_AVAILABILITY_SCHEMA = vol.Schema({ + vol.Optional(CONF_AVAILABILITY_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD_AVAILABLE, + default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, + vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, + default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, +}) + MQTT_BASE_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(SCHEMA_BASE) # Sensor type platforms subscribe to MQTT events @@ -161,7 +208,6 @@ def valid_discovery_topic(value): vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }) - # Service call validation schema MQTT_PUBLISH_SCHEMA = vol.Schema({ vol.Required(ATTR_TOPIC): valid_publish_topic, @@ -172,7 +218,13 @@ def valid_discovery_topic(value): }, required=True) -def _build_publish_data(topic, qos, retain): +# pylint: disable=invalid-name +PublishPayloadType = Union[str, bytes, int, float, None] +SubscribePayloadType = Union[str, bytes] # Only bytes if encoding is None +MessageCallbackType = Callable[[str, SubscribePayloadType, int], None] + + +def _build_publish_data(topic: Any, qos: int, retain: bool) -> ServiceDataType: """Build the arguments for the publish service without the payload.""" data = {ATTR_TOPIC: topic} if qos is not None: @@ -183,14 +235,16 @@ def _build_publish_data(topic, qos, retain): @bind_hass -def publish(hass, topic, payload, qos=None, retain=None): +def publish(hass: HomeAssistantType, topic, payload, qos=None, + retain=None) -> None: """Publish message to an MQTT topic.""" hass.add_job(async_publish, hass, topic, payload, qos, retain) @callback @bind_hass -def async_publish(hass, topic, payload, qos=None, retain=None): +def async_publish(hass: HomeAssistantType, topic: Any, payload, qos=None, + retain=None) -> None: """Publish message to an MQTT topic.""" data = _build_publish_data(topic, qos, retain) data[ATTR_PAYLOAD] = payload @@ -198,49 +252,32 @@ def async_publish(hass, topic, payload, qos=None, retain=None): @bind_hass -def publish_template(hass, topic, payload_template, qos=None, retain=None): +def publish_template(hass: HomeAssistantType, topic, payload_template, + qos=None, retain=None) -> None: """Publish message to an MQTT topic using a template payload.""" data = _build_publish_data(topic, qos, retain) data[ATTR_PAYLOAD_TEMPLATE] = payload_template hass.services.call(DOMAIN, SERVICE_PUBLISH, data) -@asyncio.coroutine @bind_hass -def async_subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, - encoding='utf-8'): - """Subscribe to an MQTT topic.""" - @callback - def async_mqtt_topic_subscriber(dp_topic, dp_payload, dp_qos): - """Match subscribed MQTT topic.""" - if not _match_topic(topic, dp_topic): - return +async def async_subscribe(hass: HomeAssistantType, topic: str, + msg_callback: MessageCallbackType, + qos: int = DEFAULT_QOS, + encoding: str = 'utf-8'): + """Subscribe to an MQTT topic. - if encoding is not None: - try: - payload = dp_payload.decode(encoding) - _LOGGER.debug("Received message on %s: %s", dp_topic, payload) - except (AttributeError, UnicodeDecodeError): - _LOGGER.error("Illegal payload encoding %s from " - "MQTT topic: %s, Payload: %s", - encoding, dp_topic, dp_payload) - return - else: - _LOGGER.debug("Received binary message on %s", dp_topic) - payload = dp_payload - - hass.async_run_job(msg_callback, dp_topic, payload, dp_qos) - - async_remove = async_dispatcher_connect( - hass, SIGNAL_MQTT_MESSAGE_RECEIVED, async_mqtt_topic_subscriber) - - yield from hass.data[DATA_MQTT].async_subscribe(topic, qos) + Call the return value to unsubscribe. + """ + async_remove = await hass.data[DATA_MQTT].async_subscribe( + topic, msg_callback, qos, encoding) return async_remove @bind_hass -def subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, - encoding='utf-8'): +def subscribe(hass: HomeAssistantType, topic: str, + msg_callback: MessageCallbackType, qos: int = DEFAULT_QOS, + encoding: str = 'utf-8') -> Callable[[], None]: """Subscribe to an MQTT topic.""" async_remove = run_coroutine_threadsafe( async_subscribe(hass, topic, msg_callback, qos, encoding), hass.loop @@ -253,15 +290,15 @@ def remove(): return remove -@asyncio.coroutine -def _async_setup_server(hass, config): +async def _async_setup_server(hass: HomeAssistantType, + config: ConfigType): """Try to start embedded MQTT broker. This method is a coroutine. """ - conf = config.get(DOMAIN, {}) + conf = config.get(DOMAIN, {}) # type: ConfigType - server = yield from async_prepare_setup_platform( + server = await async_prepare_setup_platform( hass, config, DOMAIN, 'server') if server is None: @@ -269,60 +306,62 @@ def _async_setup_server(hass, config): return None success, broker_config = \ - yield from server.async_start(hass, conf.get(CONF_EMBEDDED)) + await server.async_start(hass, conf.get(CONF_EMBEDDED)) - return success and broker_config + if not success: + return None + return broker_config -@asyncio.coroutine -def _async_setup_discovery(hass, config): +async def _async_setup_discovery(hass: HomeAssistantType, + config: ConfigType) -> bool: """Try to start the discovery of MQTT devices. This method is a coroutine. """ - conf = config.get(DOMAIN, {}) + conf = config.get(DOMAIN, {}) # type: ConfigType - discovery = yield from async_prepare_setup_platform( + discovery = await async_prepare_setup_platform( hass, config, DOMAIN, 'discovery') if discovery is None: _LOGGER.error("Unable to load MQTT discovery") - return None + return False - success = yield from discovery.async_start( - hass, conf[CONF_DISCOVERY_PREFIX], config) + success = await discovery.async_start( + hass, conf[CONF_DISCOVERY_PREFIX], config) # type: bool return success -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Start the MQTT protocol service.""" - conf = config.get(DOMAIN) + conf = config.get(DOMAIN) # type: Optional[ConfigType] if conf is None: conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] + conf = cast(ConfigType, conf) - client_id = conf.get(CONF_CLIENT_ID) - keepalive = conf.get(CONF_KEEPALIVE) + client_id = conf.get(CONF_CLIENT_ID) # type: Optional[str] + keepalive = conf.get(CONF_KEEPALIVE) # type: int # Only setup if embedded config passed in or no broker specified if CONF_EMBEDDED not in conf and CONF_BROKER in conf: broker_config = None else: - broker_config = yield from _async_setup_server(hass, config) + broker_config = await _async_setup_server(hass, config) if CONF_BROKER in conf: - broker = conf[CONF_BROKER] - port = conf[CONF_PORT] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - certificate = conf.get(CONF_CERTIFICATE) - client_key = conf.get(CONF_CLIENT_KEY) - client_cert = conf.get(CONF_CLIENT_CERT) - tls_insecure = conf.get(CONF_TLS_INSECURE) - protocol = conf[CONF_PROTOCOL] - elif broker_config: + broker = conf[CONF_BROKER] # type: str + port = conf[CONF_PORT] # type: int + username = conf.get(CONF_USERNAME) # type: Optional[str] + password = conf.get(CONF_PASSWORD) # type: Optional[str] + certificate = conf.get(CONF_CERTIFICATE) # type: Optional[str] + client_key = conf.get(CONF_CLIENT_KEY) # type: Optional[str] + client_cert = conf.get(CONF_CLIENT_CERT) # type: Optional[str] + tls_insecure = conf.get(CONF_TLS_INSECURE) # type: Optional[bool] + protocol = conf[CONF_PROTOCOL] # type: str + elif broker_config is not None: # If no broker passed in, auto config to internal server broker, port, username, password, certificate, protocol = broker_config # Embedded broker doesn't have some ssl variables @@ -339,8 +378,8 @@ def async_setup(hass, config): return False # For cloudmqtt.com, secured connection, auto fill in certificate - if certificate is None and 19999 < port < 30000 and \ - broker.endswith('.cloudmqtt.com'): + if (certificate is None and 19999 < port < 30000 and + broker.endswith('.cloudmqtt.com')): certificate = os.path.join(os.path.dirname(__file__), 'addtrustexternalcaroot.crt') @@ -348,11 +387,15 @@ def async_setup(hass, config): if certificate == 'auto': certificate = requests.certs.where() - will_message = conf.get(CONF_WILL_MESSAGE) - birth_message = conf.get(CONF_BIRTH_MESSAGE) + will_message = None # type: Optional[Message] + if conf.get(CONF_WILL_MESSAGE) is not None: + will_message = Message(**conf.get(CONF_WILL_MESSAGE)) + birth_message = None # type: Optional[Message] + if conf.get(CONF_BIRTH_MESSAGE) is not None: + birth_message = Message(**conf.get(CONF_BIRTH_MESSAGE)) # Be able to override versions other than TLSv1.0 under Python3.6 - conf_tls_version = conf.get(CONF_TLS_VERSION) + conf_tls_version = conf.get(CONF_TLS_VERSION) # type: str if conf_tls_version == '1.2': tls_version = ssl.PROTOCOL_TLSv1_2 elif conf_tls_version == '1.1': @@ -377,60 +420,77 @@ def async_setup(hass, config): "Please check your settings and the broker itself") return False - @asyncio.coroutine - def async_stop_mqtt(event): + async def async_stop_mqtt(event: Event): """Stop MQTT component.""" - yield from hass.data[DATA_MQTT].async_disconnect() + await hass.data[DATA_MQTT].async_disconnect() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) - success = yield from hass.data[DATA_MQTT].async_connect() + success = await hass.data[DATA_MQTT].async_connect() # type: bool if not success: return False - @asyncio.coroutine - def async_publish_service(call): + async def async_publish_service(call: ServiceCall): """Handle MQTT publish service calls.""" - msg_topic = call.data[ATTR_TOPIC] + msg_topic = call.data[ATTR_TOPIC] # type: str payload = call.data.get(ATTR_PAYLOAD) payload_template = call.data.get(ATTR_PAYLOAD_TEMPLATE) - qos = call.data[ATTR_QOS] - retain = call.data[ATTR_RETAIN] + qos = call.data[ATTR_QOS] # type: int + retain = call.data[ATTR_RETAIN] # type: bool if payload_template is not None: try: payload = \ template.Template(payload_template, hass).async_render() except template.jinja2.TemplateError as exc: _LOGGER.error( - "Unable to publish to '%s': rendering payload template of " - "'%s' failed because %s", + "Unable to publish to %s: rendering payload template of " + "%s failed because %s", msg_topic, payload_template, exc) return - yield from hass.data[DATA_MQTT].async_publish( + await hass.data[DATA_MQTT].async_publish( msg_topic, payload, qos, retain) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) - hass.services.async_register( DOMAIN, SERVICE_PUBLISH, async_publish_service, - descriptions.get(SERVICE_PUBLISH), schema=MQTT_PUBLISH_SCHEMA) + schema=MQTT_PUBLISH_SCHEMA) if conf.get(CONF_DISCOVERY): - yield from _async_setup_discovery(hass, config) + await _async_setup_discovery(hass, config) return True +@attr.s(slots=True, frozen=True) +class Subscription(object): + """Class to hold data about an active subscription.""" + + topic = attr.ib(type=str) + callback = attr.ib(type=MessageCallbackType) + qos = attr.ib(type=int, default=0) + encoding = attr.ib(type=str, default='utf-8') + + +@attr.s(slots=True, frozen=True) +class Message(object): + """MQTT Message.""" + + topic = attr.ib(type=str) + payload = attr.ib(type=PublishPayloadType) + qos = attr.ib(type=int, default=0) + retain = attr.ib(type=bool, default=False) + + class MQTT(object): """Home Assistant MQTT client.""" - def __init__(self, hass, broker, port, client_id, keepalive, username, - password, certificate, client_key, client_cert, - tls_insecure, protocol, will_message, birth_message, - tls_version): + def __init__(self, hass: HomeAssistantType, broker: str, port: int, + client_id: Optional[str], keepalive: Optional[int], + username: Optional[str], password: Optional[str], + certificate: Optional[str], client_key: Optional[str], + client_cert: Optional[str], tls_insecure: Optional[bool], + protocol: Optional[str], will_message: Optional[Message], + birth_message: Optional[Message], tls_version) -> None: """Initialize Home Assistant MQTT client.""" import paho.mqtt.client as mqtt @@ -438,14 +498,13 @@ def __init__(self, hass, broker, port, client_id, keepalive, username, self.broker = broker self.port = port self.keepalive = keepalive - self.topics = {} - self.progress = {} + self.subscriptions = [] # type: List[Subscription] self.birth_message = birth_message - self._mqttc = None + self._mqttc = None # type: mqtt.Client self._paho_lock = asyncio.Lock(loop=hass.loop) if protocol == PROTOCOL_31: - proto = mqtt.MQTTv31 + proto = mqtt.MQTTv31 # type: int else: proto = mqtt.MQTTv311 @@ -465,45 +524,45 @@ def __init__(self, hass, broker, port, client_id, keepalive, username, if tls_insecure is not None: self._mqttc.tls_insecure_set(tls_insecure) - self._mqttc.on_subscribe = self._mqtt_on_subscribe - self._mqttc.on_unsubscribe = self._mqtt_on_unsubscribe self._mqttc.on_connect = self._mqtt_on_connect self._mqttc.on_disconnect = self._mqtt_on_disconnect self._mqttc.on_message = self._mqtt_on_message - if will_message: - self._mqttc.will_set(will_message.get(ATTR_TOPIC), - will_message.get(ATTR_PAYLOAD), - will_message.get(ATTR_QOS), - will_message.get(ATTR_RETAIN)) + if will_message is not None: + self._mqttc.will_set(*attr.astuple(will_message)) - @asyncio.coroutine - def async_publish(self, topic, payload, qos, retain): + async def async_publish(self, topic: str, payload: PublishPayloadType, + qos: int, retain: bool) -> None: """Publish a MQTT message. This method must be run in the event loop and returns a coroutine. """ - with (yield from self._paho_lock): - yield from self.hass.async_add_job( + async with self._paho_lock: + await self.hass.async_add_job( self._mqttc.publish, topic, payload, qos, retain) - @asyncio.coroutine - def async_connect(self): + async def async_connect(self) -> bool: """Connect to the host. Does process messages yet. This method is a coroutine. """ - result = yield from self.hass.async_add_job( - self._mqttc.connect, self.broker, self.port, self.keepalive) + result = None # type: int + try: + result = await self.hass.async_add_job( + self._mqttc.connect, self.broker, self.port, self.keepalive) + except OSError as err: + _LOGGER.error('Failed to connect due to exception: %s', err) + return False if result != 0: import paho.mqtt.client as mqtt _LOGGER.error('Failed to connect: %s', mqtt.error_string(result)) - else: - self._mqttc.loop_start() + return False - return not result + self._mqttc.loop_start() + return True + @callback def async_disconnect(self): """Stop the MQTT client. @@ -516,39 +575,58 @@ def stop(): return self.hass.async_add_job(stop) - @asyncio.coroutine - def async_subscribe(self, topic, qos): - """Subscribe to a topic. + async def async_subscribe(self, topic: str, + msg_callback: MessageCallbackType, + qos: int, encoding: str) -> Callable[[], None]: + """Set up a subscription to a topic with the provided qos. This method is a coroutine. """ if not isinstance(topic, str): - raise HomeAssistantError("topic need to be a string!") + raise HomeAssistantError("topic needs to be a string!") - with (yield from self._paho_lock): - if topic in self.topics: - return + subscription = Subscription(topic, msg_callback, qos, encoding) + self.subscriptions.append(subscription) - result, mid = yield from self.hass.async_add_job( - self._mqttc.subscribe, topic, qos) + await self._async_perform_subscription(topic, qos) - _raise_on_error(result) - self.progress[mid] = topic - self.topics[topic] = None + @callback + def async_remove() -> None: + """Remove subscription.""" + if subscription not in self.subscriptions: + raise HomeAssistantError("Can't remove subscription twice") + self.subscriptions.remove(subscription) - @asyncio.coroutine - def async_unsubscribe(self, topic): - """Unsubscribe from topic. + if any(other.topic == topic for other in self.subscriptions): + # Other subscriptions on topic remaining - don't unsubscribe. + return + self.hass.async_add_job(self._async_unsubscribe(topic)) + + return async_remove + + async def _async_unsubscribe(self, topic: str) -> None: + """Unsubscribe from a topic. This method is a coroutine. """ - result, mid = yield from self.hass.async_add_job( - self._mqttc.unsubscribe, topic) + async with self._paho_lock: + result = None # type: int + result, _ = await self.hass.async_add_job( + self._mqttc.unsubscribe, topic) + _raise_on_error(result) - _raise_on_error(result) - self.progress[mid] = topic + async def _async_perform_subscription(self, topic: str, qos: int) -> None: + """Perform a paho-mqtt subscription.""" + _LOGGER.debug("Subscribing to %s", topic) + + async with self._paho_lock: + result = None # type: int + result, _ = await self.hass.async_add_job( + self._mqttc.subscribe, topic, qos) + _raise_on_error(result) - def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code): + def _mqtt_on_connect(self, _mqttc, _userdata, _flags, + result_code: int) -> None: """On connect callback. Resubscribe to all topics we were subscribed to and publish birth @@ -562,61 +640,51 @@ def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code): self._mqttc.disconnect() return - old_topics = self.topics - - self.topics = {key: value for key, value in self.topics.items() - if value is None} - - for topic, qos in old_topics.items(): - # qos is None if we were in process of subscribing - if qos is not None: - self.hass.add_job(self.async_subscribe, topic, qos) + # Group subscriptions to only re-subscribe once for each topic. + keyfunc = attrgetter('topic') + for topic, subs in groupby(sorted(self.subscriptions, key=keyfunc), + keyfunc): + # Re-subscribe with the highest requested qos + max_qos = max(subscription.qos for subscription in subs) + self.hass.add_job(self._async_perform_subscription, topic, max_qos) if self.birth_message: - self.hass.add_job(self.async_publish( - self.birth_message.get(ATTR_TOPIC), - self.birth_message.get(ATTR_PAYLOAD), - self.birth_message.get(ATTR_QOS), - self.birth_message.get(ATTR_RETAIN))) - - def _mqtt_on_subscribe(self, _mqttc, _userdata, mid, granted_qos): - """Subscribe successful callback.""" - topic = self.progress.pop(mid, None) - if topic is None: - return - self.topics[topic] = granted_qos[0] + self.hass.add_job( + self.async_publish(*attr.astuple(self.birth_message))) - def _mqtt_on_message(self, _mqttc, _userdata, msg): + def _mqtt_on_message(self, _mqttc, _userdata, msg) -> None: """Message received callback.""" - dispatcher_send( - self.hass, SIGNAL_MQTT_MESSAGE_RECEIVED, msg.topic, msg.payload, - msg.qos - ) - - def _mqtt_on_unsubscribe(self, _mqttc, _userdata, mid, granted_qos): - """Unsubscribe successful callback.""" - topic = self.progress.pop(mid, None) - if topic is None: - return - self.topics.pop(topic, None) + self.hass.add_job(self._mqtt_handle_message, msg) - def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code): + @callback + def _mqtt_handle_message(self, msg) -> None: + _LOGGER.debug("Received message on %s: %s", msg.topic, msg.payload) + + for subscription in self.subscriptions: + if not _match_topic(subscription.topic, msg.topic): + continue + + payload = msg.payload # type: SubscribePayloadType + if subscription.encoding is not None: + try: + payload = msg.payload.decode(subscription.encoding) + except (AttributeError, UnicodeDecodeError): + _LOGGER.warning("Can't decode payload %s on %s " + "with encoding %s", + msg.payload, msg.topic, + subscription.encoding) + continue + + self.hass.async_run_job(subscription.callback, + msg.topic, payload, msg.qos) + + def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None: """Disconnected callback.""" - self.progress = {} - self.topics = {key: value for key, value in self.topics.items() - if value is not None} - - # Remove None values from topic list - for key in list(self.topics): - if self.topics[key] is None: - self.topics.pop(key) - # When disconnected because of calling disconnect() if result_code == 0: return tries = 0 - wait_time = 0 while True: try: @@ -635,18 +703,18 @@ def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code): tries += 1 -def _raise_on_error(result): +def _raise_on_error(result_code: int) -> None: """Raise error if error result.""" - if result != 0: + if result_code != 0: import paho.mqtt.client as mqtt raise HomeAssistantError( - 'Error talking to MQTT: {}'.format(mqtt.error_string(result))) + 'Error talking to MQTT: {}'.format(mqtt.error_string(result_code))) -def _match_topic(subscription, topic): +def _match_topic(subscription: str, topic: str) -> bool: """Test if topic matches subscription.""" - reg_ex_parts = [] + reg_ex_parts = [] # type: List[str] suffix = "" if subscription.endswith('#'): subscription = subscription[:-2] @@ -663,3 +731,44 @@ def _match_topic(subscription, topic): reg = re.compile(reg_ex) return reg.match(topic) is not None + + +class MqttAvailability(Entity): + """Mixin used for platforms that report availability.""" + + def __init__(self, availability_topic: Optional[str], qos: Optional[int], + payload_available: Optional[str], + payload_not_available: Optional[str]) -> None: + """Initialize the availability mixin.""" + self._availability_topic = availability_topic + self._availability_qos = qos + self._available = availability_topic is None # type: bool + self._payload_available = payload_available + self._payload_not_available = payload_not_available + + async def async_added_to_hass(self) -> None: + """Subscribe mqtt events. + + This method must be run in the event loop and returns a coroutine. + """ + @callback + def availability_message_received(topic: str, + payload: SubscribePayloadType, + qos: int) -> None: + """Handle a new received MQTT availability message.""" + if payload == self._payload_available: + self._available = True + elif payload == self._payload_not_available: + self._available = False + + self.async_schedule_update_ha_state() + + if self._availability_topic is not None: + await async_subscribe( + self.hass, self._availability_topic, + availability_message_received, self._availability_qos) + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self._available diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index f76c4e9d527b2..d5a3b4a2efb7e 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -4,7 +4,6 @@ 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 @@ -20,11 +19,17 @@ r'(?P\w+)/(?P\w+)/' r'(?:(?P[a-zA-Z0-9_-]+)/)?(?P[a-zA-Z0-9_-]+)/config') -SUPPORTED_COMPONENTS = ['binary_sensor', 'light', 'sensor', 'switch'] +SUPPORTED_COMPONENTS = [ + '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'], } @@ -32,19 +37,16 @@ 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) @@ -85,10 +87,10 @@ def async_device_message_received(topic, payload, qos): _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 0e866723b345e..8a01292879258 100644 --- a/homeassistant/components/mqtt/server.py +++ b/homeassistant/components/mqtt/server.py @@ -13,7 +13,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['hbmqtt==0.8'] +REQUIREMENTS = ['hbmqtt==0.9.2'] DEPENDENCIES = ['http'] # None allows custom config to be created through generate_config diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index 9c713787fac77..e338e21802a02 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -1,28 +1,25 @@ -publish: - description: Publish a message to an MQTT topic +# Describes the format for available MQTT services +publish: + description: Publish a message to an MQTT topic. fields: topic: - description: Topic to publish payload + description: Topic to publish payload. example: /homeassistant/hello - payload: - description: Payload to publish + description: Payload to publish. example: This is great - payload_template: description: Template to render as payload value. Ignored if payload given. example: "{{ states('sensor.temperature') }}" - qos: - description: Quality of Service + description: Quality of Service to use. example: 2 values: - 0 - 1 - 2 default: 0 - retain: description: If message should have the retain flag set. example: true diff --git a/homeassistant/components/mqtt_eventstream.py b/homeassistant/components/mqtt_eventstream.py index 40a752807ede0..aa67057817237 100644 --- a/homeassistant/components/mqtt_eventstream.py +++ b/homeassistant/components/mqtt_eventstream.py @@ -10,7 +10,6 @@ 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 ( @@ -26,6 +25,7 @@ CONF_PUBLISH_TOPIC = 'publish_topic' CONF_SUBSCRIBE_TOPIC = 'subscribe_topic' CONF_PUBLISH_EVENTSTREAM_RECEIVED = 'publish_eventstream_received' +CONF_IGNORE_EVENT = 'ignore_event' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -33,6 +33,7 @@ vol.Optional(CONF_SUBSCRIBE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PUBLISH_EVENTSTREAM_RECEIVED, default=False): cv.boolean, + vol.Optional(CONF_IGNORE_EVENT, default=[]): cv.ensure_list }), }, extra=vol.ALLOW_EXTRA) @@ -40,10 +41,11 @@ @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) + ignore_event = conf.get(CONF_IGNORE_EVENT) @callback def _event_publisher(event): @@ -53,6 +55,10 @@ def _event_publisher(event): if event.event_type == EVENT_TIME_CHANGED: return + # User-defined events to ignore + if event.event_type in ignore_event: + return + # Filter out the events that were triggered by publishing # to the MQTT topic, or you will end up in an infinite loop. if event.event_type == EVENT_CALL_SERVICE: @@ -75,7 +81,7 @@ def _event_publisher(event): 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: @@ -108,7 +114,7 @@ def _event_receiver(topic, payload, qos): # 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 new file mode 100644 index 0000000000000..205a638c57412 --- /dev/null +++ b/homeassistant/components/mqtt_statestream.py @@ -0,0 +1,96 @@ +""" +Publish simple item state changes via MQTT. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mqtt_statestream/ +""" +import asyncio +import json + +import voluptuous as vol + +from homeassistant.const import (CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, + CONF_INCLUDE, MATCH_ALL) +from homeassistant.core import callback +from homeassistant.components.mqtt import valid_publish_topic +from homeassistant.helpers.entityfilter import generate_filter +from homeassistant.helpers.event import async_track_state_change +from homeassistant.remote import JSONEncoder +import homeassistant.helpers.config_validation as cv + +CONF_BASE_TOPIC = 'base_topic' +CONF_PUBLISH_ATTRIBUTES = 'publish_attributes' +CONF_PUBLISH_TIMESTAMPS = 'publish_timestamps' +DEPENDENCIES = ['mqtt'] +DOMAIN = 'mqtt_statestream' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_EXCLUDE, default={}): 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_INCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }), + vol.Required(CONF_BASE_TOPIC): valid_publish_topic, + vol.Optional(CONF_PUBLISH_ATTRIBUTES, default=False): cv.boolean, + vol.Optional(CONF_PUBLISH_TIMESTAMPS, default=False): cv.boolean + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the MQTT state feed.""" + conf = config.get(DOMAIN, {}) + base_topic = conf.get(CONF_BASE_TOPIC) + pub_include = conf.get(CONF_INCLUDE, {}) + pub_exclude = conf.get(CONF_EXCLUDE, {}) + publish_attributes = conf.get(CONF_PUBLISH_ATTRIBUTES) + publish_timestamps = conf.get(CONF_PUBLISH_TIMESTAMPS) + publish_filter = generate_filter(pub_include.get(CONF_DOMAINS, []), + pub_include.get(CONF_ENTITIES, []), + pub_exclude.get(CONF_DOMAINS, []), + pub_exclude.get(CONF_ENTITIES, [])) + if not base_topic.endswith('/'): + base_topic = base_topic + '/' + + @callback + def _state_publisher(entity_id, old_state, new_state): + if new_state is None: + return + + if not publish_filter(entity_id): + return + + payload = new_state.state + + mybase = base_topic + entity_id.replace('.', '/') + '/' + hass.components.mqtt.async_publish(mybase + 'state', payload, 1, True) + + if publish_timestamps: + if new_state.last_updated: + hass.components.mqtt.async_publish( + mybase + 'last_updated', + new_state.last_updated.isoformat(), + 1, + True) + if new_state.last_changed: + hass.components.mqtt.async_publish( + mybase + 'last_changed', + new_state.last_changed.isoformat(), + 1, + True) + + if publish_attributes: + for key, val in new_state.attributes.items(): + 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 new file mode 100644 index 0000000000000..678cdf10c567c --- /dev/null +++ b/homeassistant/components/mychevy.py @@ -0,0 +1,130 @@ +""" +MyChevy Component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/mychevy/ +""" +from datetime import timedelta +import logging +import threading +import time + +import voluptuous as vol + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +REQUIREMENTS = ["mychevy==0.1.1"] + +DOMAIN = 'mychevy' +UPDATE_TOPIC = DOMAIN +ERROR_TOPIC = DOMAIN + "_error" + +MYCHEVY_SUCCESS = "success" +MYCHEVY_ERROR = "error" + +NOTIFICATION_ID = 'mychevy_website_notification' +NOTIFICATION_TITLE = 'MyChevy website status' + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) +ERROR_SLEEP_TIME = timedelta(minutes=30) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +class EVSensorConfig(object): + """The EV sensor configuration.""" + + def __init__(self, name, attr, unit_of_measurement=None, icon=None): + """Create new sensor configuration.""" + self.name = name + self.attr = attr + self.unit_of_measurement = unit_of_measurement + self.icon = icon + + +class EVBinarySensorConfig(object): + """The EV binary sensor configuration.""" + + def __init__(self, name, attr, device_class=None): + """Create new binary sensor configuration.""" + self.name = name + self.attr = attr + self.device_class = device_class + + +def setup(hass, base_config): + """Set up the mychevy component.""" + import mychevy.mychevy as mc + + config = base_config.get(DOMAIN) + + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + 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 + + +class MyChevyHub(threading.Thread): + """MyChevy Hub. + + Connecting to the mychevy website is done through a selenium + webscraping process. That can only run synchronously. In order to + prevent blocking of other parts of Home Assistant the architecture + launches a polling loop in a thread. + + When new data is received, sensors are updated, and hass is + signaled that there are updates. Sensors are not created until the + first update, which will be 60 - 120 seconds after the platform + starts. + """ + + def __init__(self, client, hass): + """Initialize MyChevy Hub.""" + super().__init__() + self._client = client + self.hass = hass + self.car = None + self.status = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update sensors from mychevy website. + + This is a synchronous polling call that takes a very long time + (like 2 to 3 minutes long time) + + """ + self.car = self._client.data() + + def run(self): + """Thread run loop.""" + # We add the status device first outside of the loop + + # And then busy wait on threads + while True: + try: + _LOGGER.info("Starting mychevy loop") + self.update() + self.hass.helpers.dispatcher.dispatcher_send(UPDATE_TOPIC) + time.sleep(MIN_TIME_BETWEEN_UPDATES.seconds) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error updating mychevy data. " + "This probably means the OnStar link is down again") + self.hass.helpers.dispatcher.dispatcher_send(ERROR_TOPIC) + time.sleep(ERROR_SLEEP_TIME.seconds) diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index c37116fb32dcf..6721669a0266a 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -1,8 +1,8 @@ """ Connect to a MySensors gateway via pymysensors API. -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.mysensors/ +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mysensors/ """ import asyncio from collections import defaultdict @@ -17,17 +17,17 @@ 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, + 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__) @@ -76,12 +76,12 @@ def is_socket_address(value): def has_parent_dir(value): - """Validate that value is in an existing directory which is writetable.""" + """Validate that value is in an existing directory which is writeable.""" parent = os.path.dirname(os.path.realpath(value)) is_dir_writable = os.path.isdir(parent) and os.access(parent, os.W_OK) if not is_dir_writable: raise vol.Invalid( - '{} directory does not exist or is not writetable'.format(parent)) + '{} directory does not exist or is not writeable'.format(parent)) return value @@ -115,21 +115,20 @@ def is_serial_port(value): if value in ports: return value else: - raise vol.Invalid( - '{} is not a serial port'.format(value)) + raise vol.Invalid('{} is not a serial port'.format(value)) else: return cv.isdevice(value) def deprecated(key): - """Mark key as deprecated in config.""" + """Mark key as deprecated in configuration.""" def validator(config): """Check if key is in config, log warning and remove key.""" if key not in config: return config _LOGGER.warning( '%s option for %s is deprecated. Please remove %s from your ' - 'configuration file.', key, DOMAIN, key) + 'configuration file', key, DOMAIN, key) config.pop(key) return config return validator @@ -150,16 +149,11 @@ def validator(config): vol.Any(MQTT_COMPONENT, is_socket_address, is_serial_port), vol.Optional(CONF_PERSISTENCE_FILE): vol.All(cv.string, is_persistence_file, has_parent_dir), - vol.Optional( - CONF_BAUD_RATE, - default=DEFAULT_BAUD_RATE): cv.positive_int, - vol.Optional( - CONF_TCP_PORT, - default=DEFAULT_TCP_PORT): cv.port, - vol.Optional( - CONF_TOPIC_IN_PREFIX, default=''): valid_subscribe_topic, - vol.Optional( - CONF_TOPIC_OUT_PREFIX, default=''): valid_publish_topic, + vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): + cv.positive_int, + vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, + vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, + vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, }] ), @@ -171,7 +165,7 @@ def validator(config): }, extra=vol.ALLOW_EXTRA) -# mysensors const schemas +# MySensors const schemas BINARY_SENSOR_SCHEMA = {PLATFORM: 'binary_sensor', TYPE: 'V_TRIPPED'} CLIMATE_SCHEMA = {PLATFORM: 'climate', TYPE: 'V_HVAC_FLOW_STATE'} LIGHT_DIMMER_SCHEMA = { @@ -287,67 +281,62 @@ def validator(config): } -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 @@ -362,9 +351,9 @@ def gw_start(event): hass.config.path('mysensors{}.pickle'.format(index + 1))) baud_rate = gway.get(CONF_BAUD_RATE) 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( + in_prefix = gway.get(CONF_TOPIC_IN_PREFIX, '') + out_prefix = gway.get(CONF_TOPIC_OUT_PREFIX, '') + ready_gateway = await setup_gateway( device, persistence_file, baud_rate, tcp_port, in_prefix, out_prefix) if ready_gateway is not None: @@ -378,9 +367,36 @@ def gw_start(event): 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) + + def validate_child(gateway, node_id, child): """Validate that a child has the correct values according to schema. @@ -438,14 +454,18 @@ def msg(name): 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}) + """Discover a MySensors platform.""" + 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] @@ -454,11 +474,13 @@ 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): - """Return mysensors devices for a platform.""" + """Return MySensors devices for a platform.""" if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data: hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} return hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] @@ -466,16 +488,17 @@ def get_mysensors_devices(hass, domain): def gw_callback_factory(hass): """Return a new callback for the gateway.""" + @callback def mysensors_callback(msg): - """Default callback for a mysensors gateway.""" + """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: - _LOGGER.debug( - "Not a child update for node %s", msg.node_id) + 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 signals = [] @@ -497,7 +520,7 @@ def mysensors_callback(msg): # 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,17 +541,18 @@ def get_mysensors_name(gateway, node_id, child_id): def get_mysensors_gateway(hass, gateway_id): - """Return gateway.""" + """Return MySensors gateway.""" if MYSENSORS_GATEWAYS not in hass.data: hass.data[MYSENSORS_GATEWAYS] = {} gateways = hass.data.get(MYSENSORS_GATEWAYS) return gateways.get(gateway_id) +@callback def setup_mysensors_platform( hass, domain, discovery_info, device_class, device_args=None, - add_devices=None): - """Set up a mysensors platform.""" + async_add_devices=None): + """Set up a MySensors platform.""" # Only act if called via mysensors by discovery event. # Otherwise gateway is not setup. if not discovery_info: @@ -552,15 +576,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 @@ -603,7 +626,7 @@ def device_state_attributes(self): 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] @@ -627,7 +650,7 @@ class MySensorsEntity(MySensorsDevice, Entity): @property def should_poll(self): - """Mysensor gateway pushes its state to HA.""" + """Return the polling state. The gateway pushes its states.""" return False @property @@ -635,14 +658,14 @@ def available(self): """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.hass.async_add_job(self.async_update_ha_state(True)) + 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/namecheapdns.py b/homeassistant/components/namecheapdns.py new file mode 100644 index 0000000000000..dcca882953520 --- /dev/null +++ b/homeassistant/components/namecheapdns.py @@ -0,0 +1,79 @@ +""" +Integrate with namecheap DNS services. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/namecheapdns/ +""" +import asyncio +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_DOMAIN +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'namecheapdns' + +INTERVAL = timedelta(minutes=5) + +UPDATE_URL = 'https://dynamicdns.park-your-domain.com/update' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_HOST, default='@'): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the namecheap DNS component.""" + host = config[DOMAIN][CONF_HOST] + domain = config[DOMAIN][CONF_DOMAIN] + password = config[DOMAIN][CONF_PASSWORD] + + session = async_get_clientsession(hass) + + result = yield from _update_namecheapdns(session, host, domain, password) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the namecheap DNS entry.""" + yield from _update_namecheapdns(session, host, domain, password) + + async_track_time_interval(hass, update_domain_interval, INTERVAL) + + return result + + +@asyncio.coroutine +def _update_namecheapdns(session, host, domain, password): + """Update namecheap DNS entry.""" + import xml.etree.ElementTree as ET + + params = { + 'host': host, + 'domain': domain, + 'password': password, + } + + resp = yield from session.get(UPDATE_URL, params=params) + xml_string = yield from resp.text() + root = ET.fromstring(xml_string) + err_count = root.find('ErrCount').text + + if int(err_count) != 0: + _LOGGER.warning("Updating namecheap domain failed: %s", domain) + return False + + return True diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index 2401bc6604fd5..7402bb18843ad 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -17,8 +17,8 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['https://github.com/jabesq/pybotvac/archive/v0.0.3.zip' - '#pybotvac==0.0.3'] +REQUIREMENTS = ['https://github.com/jabesq/pybotvac/archive/v0.0.5.zip' + '#pybotvac==0.0.5'] DOMAIN = 'neato' NEATO_ROBOTS = 'neato_robots' @@ -90,7 +90,7 @@ def setup(hass, config): _LOGGER.debug("Failed to login to Neato API") return False hub.update_robots() - for component in ('camera', 'sensor', 'switch'): + for component in ('camera', 'vacuum', 'switch'): discovery.load_platform(hass, component, DOMAIN, {}, config) return True diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index 512819b7e743d..e7d2ba9043896 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -15,7 +15,7 @@ CONF_STRUCTURE, CONF_FILENAME, CONF_BINARY_SENSORS, CONF_SENSORS, CONF_MONITORED_CONDITIONS) -REQUIREMENTS = ['python-nest==3.1.0'] +REQUIREMENTS = ['python-nest==3.7.0'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -183,7 +183,7 @@ def thermostats(self): "Connection error logging into the nest web service.") def smoke_co_alarms(self): - """Generate a list of smoke co alarams.""" + """Generate a list of smoke co alarms.""" try: for structure in self.nest.structures: if structure.name in self.local_structure: diff --git a/homeassistant/components/netatmo.py b/homeassistant/components/netatmo.py index 606c9eef5b098..44a54c9551261 100644 --- a/homeassistant/components/netatmo.py +++ b/homeassistant/components/netatmo.py @@ -18,7 +18,7 @@ REQUIREMENTS = [ 'https://github.com/jabesq/netatmo-api-python/archive/' - 'v0.9.2.zip#lnetatmo==0.9.2'] + 'v0.9.2.1.zip#lnetatmo==0.9.2.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/no_ip.py b/homeassistant/components/no_ip.py new file mode 100644 index 0000000000000..6051fa85f552a --- /dev/null +++ b/homeassistant/components/no_ip.py @@ -0,0 +1,116 @@ +""" +Integrate with NO-IP Dynamic DNS service. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/no_ip/ +""" +import asyncio +import base64 +from datetime import timedelta +import logging + +import aiohttp +from aiohttp.hdrs import USER_AGENT, AUTHORIZATION +import async_timeout +import voluptuous as vol + +from homeassistant.const import ( + CONF_DOMAIN, CONF_TIMEOUT, CONF_PASSWORD, CONF_USERNAME) +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'no_ip' + +# We should set a dedicated address for the user agent. +EMAIL = 'hello@home-assistant.io' + +INTERVAL = timedelta(minutes=5) + +DEFAULT_TIMEOUT = 10 + +NO_IP_ERRORS = { + 'nohost': "Hostname supplied does not exist under specified account", + 'badauth': "Invalid username password combination", + 'badagent': "Client disabled", + '!donator': + "An update request was sent with a feature that is not available", + 'abuse': "Username is blocked due to abuse", + '911': "A fatal error on NO-IP's side such as a database outage", +} + +UPDATE_URL = 'https://dynupdate.noip.com/nic/update' +HA_USER_AGENT = "{} {}".format(SERVER_SOFTWARE, EMAIL) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the NO-IP component.""" + domain = config[DOMAIN].get(CONF_DOMAIN) + user = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + timeout = config[DOMAIN].get(CONF_TIMEOUT) + + auth_str = base64.b64encode('{}:{}'.format(user, password).encode('utf-8')) + + session = hass.helpers.aiohttp_client.async_get_clientsession() + + result = yield from _update_no_ip( + hass, session, domain, auth_str, timeout) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the NO-IP entry.""" + yield from _update_no_ip(hass, session, domain, auth_str, timeout) + + hass.helpers.event.async_track_time_interval( + update_domain_interval, INTERVAL) + + return True + + +@asyncio.coroutine +def _update_no_ip(hass, session, domain, auth_str, timeout): + """Update NO-IP.""" + url = UPDATE_URL + + params = { + 'hostname': domain, + } + + headers = { + AUTHORIZATION: "Basic {}".format(auth_str.decode('utf-8')), + USER_AGENT: HA_USER_AGENT, + } + + try: + with async_timeout.timeout(timeout, loop=hass.loop): + resp = yield from session.get(url, params=params, headers=headers) + body = yield from resp.text() + + if body.startswith('good') or body.startswith('nochg'): + return True + + _LOGGER.warning("Updating NO-IP failed: %s => %s", domain, + NO_IP_ERRORS[body.strip()]) + + except aiohttp.ClientError: + _LOGGER.warning("Can't connect to NO-IP API") + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout from NO-IP API for domain: %s", domain) + + return False diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 9496ff1d596ea..41198d1f29642 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -6,7 +6,6 @@ """ import asyncio import logging -import os from functools import partial import voluptuous as vol @@ -15,7 +14,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv -from homeassistant.config import load_yaml_config_file from homeassistant.const import CONF_NAME, CONF_PLATFORM from homeassistant.helpers import config_per_platform, discovery from homeassistant.util import slugify @@ -71,10 +69,6 @@ def send_message(hass, message, title=None, data=None): @asyncio.coroutine def async_setup(hass, config): """Set up the notify services.""" - descriptions = yield from hass.async_add_job( - load_yaml_config_file, - os.path.join(os.path.dirname(__file__), 'services.yaml')) - targets = {} @asyncio.coroutine @@ -151,7 +145,6 @@ def async_notify_message(service): targets[target_name] = target hass.services.async_register( DOMAIN, target_name, async_notify_message, - descriptions.get(SERVICE_NOTIFY), schema=NOTIFY_SERVICE_SCHEMA) platform_name = ( @@ -161,7 +154,7 @@ def async_notify_message(service): hass.services.async_register( DOMAIN, platform_name_slug, async_notify_message, - descriptions.get(SERVICE_NOTIFY), schema=NOTIFY_SERVICE_SCHEMA) + schema=NOTIFY_SERVICE_SCHEMA) return True diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index 136d530018327..9cca81e148552 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -12,12 +12,12 @@ 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 -REQUIREMENTS = ['apns2==0.1.1'] +REQUIREMENTS = ['apns2==0.3.0'] APNS_DEVICES = 'apns.yaml' CONF_CERTFILE = 'cert_file' @@ -27,9 +27,8 @@ 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, @@ -39,15 +38,12 @@ REGISTER_SERVICE_SCHEMA = vol.Schema({ vol.Required(ATTR_PUSH_ID): cv.string, - vol.Optional(ATTR_NAME, default=None): cv.string, + vol.Optional(ATTR_NAME): cv.string, }) def get_service(hass, config, discovery_info=None): """Return push service.""" - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - name = config.get(CONF_NAME) cert_file = config.get(CONF_CERTFILE) topic = config.get(CONF_TOPIC) @@ -56,7 +52,7 @@ def get_service(hass, config, discovery_info=None): service = ApnsNotificationService(hass, name, topic, sandbox, cert_file) hass.services.register( DOMAIN, 'apns_{}'.format(name), service.register, - descriptions.get(SERVICE_REGISTER), schema=REGISTER_SERVICE_SCHEMA) + schema=REGISTER_SERVICE_SCHEMA) return service @@ -69,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 @@ -107,21 +103,21 @@ def full_tracking_device_id(self): @property def disabled(self): - """Return the .""" + """Return the state of the service.""" return self.device_disabled def disable(self): - """Disable the device from recieving notifications.""" + """Disable the device from receiving notifications.""" self.device_disabled = True def __eq__(self, other): - """Return the comparision.""" + """Return the comparison.""" if isinstance(other, self.__class__): return self.push_id == other.push_id and self.name == other.name return NotImplemented def __ne__(self, other): - """Return the comparision.""" + """Return the comparison.""" return not self.__eq__(other) diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py index 7bdc103523d30..b0cc4a0121d5f 100644 --- a/homeassistant/components/notify/aws_lambda.py +++ b/homeassistant/components/notify/aws_lambda.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.remote import JSONEncoder -REQUIREMENTS = ['boto3==1.4.3'] +REQUIREMENTS = ['boto3==1.4.7'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py index 27fa7ac41c2a2..c94e3abaa96fc 100644 --- a/homeassistant/components/notify/aws_sns.py +++ b/homeassistant/components/notify/aws_sns.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["boto3==1.4.3"] +REQUIREMENTS = ["boto3==1.4.7"] CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' diff --git a/homeassistant/components/notify/aws_sqs.py b/homeassistant/components/notify/aws_sqs.py index 227dba14b43d8..43c04ed16d055 100644 --- a/homeassistant/components/notify/aws_sqs.py +++ b/homeassistant/components/notify/aws_sqs.py @@ -16,7 +16,7 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["boto3==1.4.3"] +REQUIREMENTS = ["boto3==1.4.7"] CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' diff --git a/homeassistant/components/notify/clickatell.py b/homeassistant/components/notify/clickatell.py new file mode 100644 index 0000000000000..6af2b4551297f --- /dev/null +++ b/homeassistant/components/notify/clickatell.py @@ -0,0 +1,52 @@ +""" +Clickatell platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.clickatell/ +""" +import logging + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_API_KEY, CONF_RECIPIENT) +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'clickatell' + +BASE_API_URL = 'https://platform.clickatell.com/messages/http/send' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_RECIPIENT): cv.string, +}) + + +def get_service(hass, config, discovery_info=None): + """Get the Clickatell notification service.""" + return ClickatellNotificationService(config) + + +class ClickatellNotificationService(BaseNotificationService): + """Implementation of a notification service for the Clickatell service.""" + + def __init__(self, config): + """Initialize the service.""" + self.api_key = config.get(CONF_API_KEY) + self.recipient = config.get(CONF_RECIPIENT) + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + data = { + 'apiKey': self.api_key, + 'to': self.recipient, + 'content': message, + } + + resp = requests.get(BASE_API_URL, params=data, timeout=5) + if (resp.status_code != 200) or (resp.status_code != 201): + _LOGGER.error("Error %s : %s", resp.status_code, resp.text) diff --git a/homeassistant/components/notify/clicksend.py b/homeassistant/components/notify/clicksend.py index 663f689a9752d..c028da2c57942 100644 --- a/homeassistant/components/notify/clicksend.py +++ b/homeassistant/components/notify/clicksend.py @@ -6,32 +6,46 @@ """ import json import logging -import requests +from aiohttp.hdrs import CONTENT_TYPE +import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_USERNAME, CONF_API_KEY, CONF_RECIPIENT, HTTP_HEADER_CONTENT_TYPE, - CONTENT_TYPE_JSON) from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import ( + CONF_API_KEY, CONF_RECIPIENT, CONF_SENDER, CONF_USERNAME, + CONTENT_TYPE_JSON) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) BASE_API_URL = 'https://rest.clicksend.com/v3' -HEADERS = {HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON} +HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} + + +def validate_sender(config): + """Set the optional sender name if sender name is not provided.""" + if CONF_SENDER in config: + return config + config[CONF_SENDER] = config[CONF_RECIPIENT] + return config + -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_RECIPIENT): cv.string, -}) +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, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_SENDER): cv.string, + }), validate_sender)) def get_service(hass, config, discovery_info=None): """Get the ClickSend notification service.""" + print("#### ", config) if _authenticate(config) is False: _LOGGER.exception("You are not authorized to access ClickSend") return None @@ -46,17 +60,25 @@ def __init__(self, config): """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.recipient, - '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) - resp = requests.post(api_url, data=json.dumps(data), headers=HEADERS, - auth=(self.username, self.api_key), timeout=5) + resp = requests.post( + api_url, data=json.dumps(data), headers=HEADERS, + auth=(self.username, self.api_key), timeout=5) obj = json.loads(resp.text) response_msg = obj['response_msg'] @@ -70,9 +92,9 @@ def send_message(self, message="", **kwargs): def _authenticate(config): """Authenticate with ClickSend.""" api_url = '{}/account'.format(BASE_API_URL) - resp = requests.get(api_url, headers=HEADERS, - auth=(config.get(CONF_USERNAME), - config.get(CONF_API_KEY)), timeout=5) + resp = requests.get( + api_url, headers=HEADERS, auth=(config.get(CONF_USERNAME), + config.get(CONF_API_KEY)), timeout=5) if resp.status_code != 200: return False diff --git a/homeassistant/components/notify/clicksend_tts.py b/homeassistant/components/notify/clicksend_tts.py new file mode 100644 index 0000000000000..26a299932900b --- /dev/null +++ b/homeassistant/components/notify/clicksend_tts.py @@ -0,0 +1,90 @@ +""" +clicksend_tts platform for notify component. + +This platform sends text to speech audio messages through clicksend + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.clicksend_tts/ +""" +import json +import logging + +from aiohttp.hdrs import CONTENT_TYPE +import requests +import voluptuous as vol + +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import ( + CONF_API_KEY, CONF_USERNAME, CONF_RECIPIENT, CONTENT_TYPE_JSON) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +BASE_API_URL = 'https://rest.clicksend.com/v3' + +HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} + +CONF_LANGUAGE = 'language' +CONF_VOICE = 'voice' + +DEFAULT_LANGUAGE = 'en-us' +DEFAULT_VOICE = 'female' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_RECIPIENT): cv.string, + vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): cv.string, + vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): cv.string, +}) + + +def get_service(hass, config, discovery_info=None): + """Get the ClickSend notification service.""" + if _authenticate(config) is False: + _LOGGER.error("You are not authorized to access ClickSend") + return None + + return ClicksendNotificationService(config) + + +class ClicksendNotificationService(BaseNotificationService): + """Implementation of a notification service for the ClickSend service.""" + + def __init__(self, config): + """Initialize the service.""" + self.username = config.get(CONF_USERNAME) + self.api_key = config.get(CONF_API_KEY) + self.recipient = config.get(CONF_RECIPIENT) + self.language = config.get(CONF_LANGUAGE) + self.voice = config.get(CONF_VOICE) + + def send_message(self, message="", **kwargs): + """Send a voice call to a user.""" + data = ({'messages': [{'source': 'hass.notify', 'from': self.recipient, + 'to': self.recipient, 'body': message, + 'lang': self.language, 'voice': self.voice}]}) + api_url = "{}/voice/send".format(BASE_API_URL) + resp = requests.post(api_url, data=json.dumps(data), headers=HEADERS, + auth=(self.username, self.api_key), timeout=5) + + obj = json.loads(resp.text) + response_msg = obj['response_msg'] + response_code = obj['response_code'] + if resp.status_code != 200: + _LOGGER.error("Error %s : %s (Code %s)", resp.status_code, + response_msg, response_code) + + +def _authenticate(config): + """Authenticate with ClickSend.""" + api_url = '{}/account'.format(BASE_API_URL) + resp = requests.get(api_url, headers=HEADERS, + auth=(config.get(CONF_USERNAME), + config.get(CONF_API_KEY)), timeout=5) + + if resp.status_code != 200: + return False + + return True diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index 90212bca025f5..dca47a46dbf76 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -12,12 +12,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ATTR_TARGET) +from homeassistant.const import CONF_TOKEN _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['discord.py==0.16.11'] - -CONF_TOKEN = 'token' +REQUIREMENTS = ['discord.py==0.16.12'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_TOKEN): cv.string diff --git a/homeassistant/components/notify/ecobee.py b/homeassistant/components/notify/ecobee.py index 2d64a2d5b47a4..c718149b4b553 100644 --- a/homeassistant/components/notify/ecobee.py +++ b/homeassistant/components/notify/ecobee.py @@ -8,14 +8,14 @@ import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components import ecobee from homeassistant.components.notify import ( BaseNotificationService, PLATFORM_SCHEMA) # NOQA -import homeassistant.helpers.config_validation as cv -DEPENDENCIES = ['ecobee'] _LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['ecobee'] CONF_INDEX = 'index' diff --git a/homeassistant/components/notify/facebook.py b/homeassistant/components/notify/facebook.py index ef85450ca63ab..b73f845ea175c 100644 --- a/homeassistant/components/notify/facebook.py +++ b/homeassistant/components/notify/facebook.py @@ -4,21 +4,24 @@ 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 import requests - import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) + ATTR_DATA, ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONTENT_TYPE_JSON +import homeassistant.helpers.config_validation as cv _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,20 +58,60 @@ def send_message(self, message="", **kwargs): _LOGGER.error("At least 1 target is required") return - for target in targets: - body = { - "recipient": {"phone_number": target}, - "message": body_message + # broadcast message + if targets[0].lower() == 'broadcast': + broadcast_create_body = {"messages": [body_message]} + _LOGGER.debug("Broadcast body %s : ", broadcast_create_body) + + 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}, + 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/free_mobile.py b/homeassistant/components/notify/free_mobile.py index 92ea75a79dcb0..a27d0495193dc 100644 --- a/homeassistant/components/notify/free_mobile.py +++ b/homeassistant/components/notify/free_mobile.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['freesms==0.1.1'] +REQUIREMENTS = ['freesms==0.1.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/gntp.py b/homeassistant/components/notify/gntp.py index b7e5b1b813aa0..1a2b65f958f7d 100644 --- a/homeassistant/components/notify/gntp.py +++ b/homeassistant/components/notify/gntp.py @@ -44,7 +44,8 @@ def get_service(hass, config, discovery_info=None): if config.get(CONF_APP_ICON) is None: icon_file = os.path.join(os.path.dirname(__file__), "..", "frontend", "www_static", "icons", "favicon-192x192.png") - app_icon = open(icon_file, 'rb').read() + with open(icon_file, 'rb') as file: + app_icon = file.read() else: app_icon = config.get(CONF_APP_ICON) diff --git a/homeassistant/components/notify/hipchat.py b/homeassistant/components/notify/hipchat.py index ee1283b982046..344827c00b4a2 100644 --- a/homeassistant/components/notify/hipchat.py +++ b/homeassistant/components/notify/hipchat.py @@ -11,14 +11,13 @@ 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_HOST +from homeassistant.const import CONF_TOKEN, CONF_HOST, CONF_ROOM REQUIREMENTS = ['hipnotify==1.0.8'] _LOGGER = logging.getLogger(__name__) CONF_COLOR = 'color' -CONF_ROOM = 'room' CONF_NOTIFY = 'notify' CONF_FORMAT = 'format' diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 7151b41824845..7ccf4f8db9066 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -4,28 +4,29 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.html5/ """ -import asyncio -import os -import logging +import datetime import json +import logging import time -import datetime import uuid +from aiohttp.hdrs import AUTHORIZATION import voluptuous as vol from voluptuous.humanize import humanize_error -from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, - HTTP_UNAUTHORIZED, URL_ROOT) -from homeassistant.util import ensure_unique_string -from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, - BaseNotificationService, PLATFORM_SCHEMA) -from homeassistant.components.http import HomeAssistantView +from homeassistant.util.json import load_json, save_json +from homeassistant.exceptions import HomeAssistantError from homeassistant.components.frontend import add_manifest_json_key +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.notify import ( + ATTR_DATA, ATTR_TITLE, ATTR_TARGET, PLATFORM_SCHEMA, ATTR_TITLE_DEFAULT, + BaseNotificationService) +from homeassistant.const import ( + URL_ROOT, HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED, HTTP_INTERNAL_SERVER_ERROR) from homeassistant.helpers import config_validation as cv +from homeassistant.util import ensure_unique_string -REQUIREMENTS = ['pywebpush==1.0.6', 'PyJWT==1.5.2'] +REQUIREMENTS = ['pywebpush==1.6.0', 'PyJWT==1.6.0'] DEPENDENCIES = ['frontend'] @@ -62,24 +63,25 @@ # is valid. JWT_VALID_DAYS = 7 -KEYS_SCHEMA = vol.All(dict, - vol.Schema({ - vol.Required(ATTR_AUTH): cv.string, - vol.Required(ATTR_P256DH): cv.string - })) - -SUBSCRIPTION_SCHEMA = vol.All(dict, - vol.Schema({ - # pylint: disable=no-value-for-parameter - vol.Required(ATTR_ENDPOINT): vol.Url(), - vol.Required(ATTR_KEYS): KEYS_SCHEMA, - vol.Optional(ATTR_EXPIRATIONTIME): - vol.Any(None, cv.positive_int) - })) +KEYS_SCHEMA = vol.All( + dict, vol.Schema({ + vol.Required(ATTR_AUTH): cv.string, + vol.Required(ATTR_P256DH): cv.string, + }) +) + +SUBSCRIPTION_SCHEMA = vol.All( + dict, vol.Schema({ + # pylint: disable=no-value-for-parameter + vol.Required(ATTR_ENDPOINT): vol.Url(), + vol.Required(ATTR_KEYS): KEYS_SCHEMA, + vol.Optional(ATTR_EXPIRATIONTIME): vol.Any(None, cv.positive_int), + }) +) REGISTER_SCHEMA = vol.Schema({ vol.Required(ATTR_SUBSCRIPTION): SUBSCRIPTION_SCHEMA, - vol.Required(ATTR_BROWSER): vol.In(['chrome', 'firefox']) + vol.Required(ATTR_BROWSER): vol.In(['chrome', 'firefox']), }) CALLBACK_EVENT_PAYLOAD_SCHEMA = vol.Schema({ @@ -94,8 +96,8 @@ # Badge and timestamp are Chrome specific (not in official spec) HTML5_SHOWNOTIFICATION_PARAMETERS = ( - 'actions', 'badge', 'body', 'dir', 'icon', 'lang', 'renotify', - 'requireInteraction', 'tag', 'timestamp', 'vibrate') + 'actions', 'badge', 'body', 'dir', 'icon', 'image', 'lang', + 'renotify', 'requireInteraction', 'tag', 'timestamp', 'vibrate') def get_service(hass, config, discovery_info=None): @@ -123,46 +125,24 @@ def get_service(hass, config, discovery_info=None): def _load_config(filename): """Load configuration.""" - if not os.path.isfile(filename): - return {} - try: - with open(filename, 'r') as fdesc: - inp = fdesc.read() - - # In case empty file - if not inp: - return {} - - return json.loads(inp) - except (IOError, ValueError) as error: - _LOGGER.error("Reading config file %s failed: %s", filename, error) - return None + return load_json(filename) + except HomeAssistantError: + pass + return {} class JSONBytesDecoder(json.JSONEncoder): """JSONEncoder to decode bytes objects to unicode.""" - # pylint: disable=method-hidden + # pylint: disable=method-hidden, arguments-differ def default(self, obj): - """Decode object if it's a bytes object, else defer to baseclass.""" + """Decode object if it's a bytes object, else defer to base class.""" if isinstance(obj, bytes): return obj.decode() return json.JSONEncoder.default(self, obj) -def _save_config(filename, config): - """Save configuration.""" - try: - with open(filename, 'w') as fdesc: - fdesc.write(json.dumps( - config, cls=JSONBytesDecoder, indent=4, sort_keys=True)) - except (IOError, TypeError) as error: - _LOGGER.error("Saving config file failed: %s", error) - return False - return True - - class HTML5PushRegistrationView(HomeAssistantView): """Accepts push registrations from a browser.""" @@ -174,11 +154,10 @@ def __init__(self, registrations, json_path): self.registrations = registrations self.json_path = json_path - @asyncio.coroutine - def post(self, request): + async def post(self, request): """Accept the POST request for push registrations from a browser.""" try: - data = yield from request.json() + data = await request.json() except ValueError: return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) @@ -188,21 +167,40 @@ def post(self, request): return self.json_message( humanize_error(data, ex), HTTP_BAD_REQUEST) - name = ensure_unique_string('unnamed device', self.registrations) + name = self.find_registration_name(data) + previous_registration = self.registrations.get(name) self.registrations[name] = data - if not _save_config(self.json_path, self.registrations): + try: + hass = request.app['hass'] + + await hass.async_add_job(save_json, self.json_path, + self.registrations) + return self.json_message( + 'Push notification subscriber registered.') + except HomeAssistantError: + if previous_registration is not None: + self.registrations[name] = previous_registration + else: + self.registrations.pop(name) + return self.json_message( 'Error saving registration.', HTTP_INTERNAL_SERVER_ERROR) - return self.json_message('Push notification subscriber registered.') + def find_registration_name(self, data): + """Find a registration name matching data or generate a unique one.""" + endpoint = data.get(ATTR_SUBSCRIPTION).get(ATTR_ENDPOINT) + for key, registration in self.registrations.items(): + subscription = registration.get(ATTR_SUBSCRIPTION) + if subscription.get(ATTR_ENDPOINT) == endpoint: + return key + return ensure_unique_string('unnamed device', self.registrations) - @asyncio.coroutine - def delete(self, request): + async def delete(self, request): """Delete a registration.""" try: - data = yield from request.json() + data = await request.json() except ValueError: return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) @@ -221,7 +219,12 @@ def delete(self, request): reg = self.registrations.pop(found) - if not _save_config(self.json_path, self.registrations): + try: + hass = request.app['hass'] + + await hass.async_add_job(save_json, self.json_path, + self.registrations) + except HomeAssistantError: self.registrations[found] = reg return self.json_message( 'Error saving registration.', HTTP_INTERNAL_SERVER_ERROR) @@ -249,12 +252,12 @@ def decode_jwt(self, token): # 2a. If decode is successful, return the payload. # 2b. If decode is unsuccessful, return a 401. - target_check = jwt.decode(token, options={'verify_signature': False}) + target_check = jwt.decode(token, verify=False) if target_check[ATTR_TARGET] in self.registrations: possible_target = self.registrations[target_check[ATTR_TARGET]] key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] try: - return jwt.decode(token, key) + return jwt.decode(token, key, algorithms=["ES256", "HS256"]) except jwt.exceptions.DecodeError: pass @@ -266,7 +269,7 @@ def decode_jwt(self, token): def check_authorization_header(self, request): """Check the authorization header.""" import jwt - auth = request.headers.get('Authorization', None) + auth = request.headers.get(AUTHORIZATION, None) if not auth: return self.json_message('Authorization header is expected', status_code=HTTP_UNAUTHORIZED) @@ -290,15 +293,14 @@ def check_authorization_header(self, request): status_code=HTTP_UNAUTHORIZED) return payload - @asyncio.coroutine - def post(self, request): + async def post(self, request): """Accept the POST request for push registrations event callback.""" auth_check = self.check_authorization_header(request) if not isinstance(auth_check, dict): return auth_check try: - data = yield from request.json() + data = await request.json() except ValueError: return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) @@ -323,8 +325,7 @@ def post(self, request): event_name = '{}.{}'.format(NOTIFY_CALLBACK_EVENT, event_payload[ATTR_TYPE]) request.app['hass'].bus.fire(event_name, event_payload) - return self.json({'status': 'ok', - 'event': event_payload[ATTR_TYPE]}) + return self.json({'status': 'ok', 'event': event_payload[ATTR_TYPE]}) class HTML5NotificationService(BaseNotificationService): @@ -403,16 +404,22 @@ def send_message(self, message="", **kwargs): jwt_token = jwt.encode(jwt_claims, jwt_secret).decode('utf-8') payload[ATTR_DATA][ATTR_JWT] = jwt_token + # Only pass the gcm key if we're actually using GCM + # If we don't, notifications break on FireFox + gcm_key = self._gcm_key \ + if 'googleapis.com' in info[ATTR_SUBSCRIPTION][ATTR_ENDPOINT] \ + else None response = WebPusher(info[ATTR_SUBSCRIPTION]).send( - json.dumps(payload), gcm_key=self._gcm_key, ttl='86400') + json.dumps(payload), gcm_key=gcm_key, ttl='86400' + ) # pylint: disable=no-member if response.status_code == 410: _LOGGER.info("Notification channel has expired") reg = self.registrations.pop(target) - if not _save_config(self.registrations_json_path, - self.registrations): + if not save_json(self.registrations_json_path, + self.registrations): self.registrations[target] = reg - _LOGGER.error("Error saving registration.") + _LOGGER.error("Error saving registration") else: _LOGGER.info("Configuration saved") diff --git a/homeassistant/components/notify/instapush.py b/homeassistant/components/notify/instapush.py index 39cdf0fc475ef..e792045ec8010 100644 --- a/homeassistant/components/notify/instapush.py +++ b/homeassistant/components/notify/instapush.py @@ -7,14 +7,14 @@ import json import logging +from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import ( - CONF_API_KEY, HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON) + ATTR_TITLE, PLATFORM_SCHEMA, ATTR_TITLE_DEFAULT, BaseNotificationService) +from homeassistant.const import CONF_API_KEY, CONTENT_TYPE_JSON +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://api.instapush.im/v1/' @@ -76,7 +76,7 @@ def __init__(self, api_key, app_secret, event, tracker): self._headers = { HTTP_HEADER_APPID: self._api_key, HTTP_HEADER_APPSECRET: self._app_secret, - HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON, + CONTENT_TYPE: CONTENT_TYPE_JSON, } def send_message(self, message="", **kwargs): diff --git a/homeassistant/components/notify/knx.py b/homeassistant/components/notify/knx.py index c5dbcb0d4ad3e..750e39455696e 100644 --- a/homeassistant/components/notify/knx.py +++ b/homeassistant/components/notify/knx.py @@ -4,7 +4,7 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/notify.knx/ """ -import asyncio + import voluptuous as vol from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES @@ -24,13 +24,8 @@ }) -@asyncio.coroutine -def async_get_service(hass, config, discovery_info=None): +async def async_get_service(hass, config, discovery_info=None): """Get the KNX notification service.""" - if DATA_KNX not in hass.data \ - or not hass.data[DATA_KNX].initialized: - return False - return async_get_service_discovery(hass, discovery_info) \ if discovery_info is not None else \ async_get_service_config(hass, config) @@ -44,29 +39,28 @@ def async_get_service_discovery(hass, discovery_info): device = hass.data[DATA_KNX].xknx.devices[device_name] notification_devices.append(device) return \ - KNXNotificationService(hass, notification_devices) \ + KNXNotificationService(notification_devices) \ if notification_devices else \ None @callback def async_get_service_config(hass, config): - """Set up notification for KNX platform configured within plattform.""" + """Set up notification for KNX platform configured within platform.""" import xknx notification = xknx.devices.Notification( hass.data[DATA_KNX].xknx, name=config.get(CONF_NAME), group_address=config.get(CONF_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(notification) - return KNXNotificationService(hass, [notification, ]) + return KNXNotificationService([notification, ]) class KNXNotificationService(BaseNotificationService): """Implement demo notification service.""" - def __init__(self, hass, devices): + def __init__(self, devices): """Initialize the service.""" - self.hass = hass self.devices = devices @property @@ -77,23 +71,20 @@ def targets(self): ret[device.name] = device.name return ret - @asyncio.coroutine - def async_send_message(self, message="", **kwargs): + async def async_send_message(self, message="", **kwargs): """Send a notification to knx bus.""" if "target" in kwargs: - yield from self._async_send_to_device(message, kwargs["target"]) + await self._async_send_to_device(message, kwargs["target"]) else: - yield from self._async_send_to_all_devices(message) + await self._async_send_to_all_devices(message) - @asyncio.coroutine - def _async_send_to_all_devices(self, message): + async def _async_send_to_all_devices(self, message): """Send a notification to knx bus to all connected devices.""" for device in self.devices: - yield from device.set(message) + await device.set(message) - @asyncio.coroutine - def _async_send_to_device(self, message, names): + async def _async_send_to_device(self, message, names): """Send a notification to knx bus to device with given names.""" for device in self.devices: if device.name in names: - yield from device.set(message) + await device.set(message) diff --git a/homeassistant/components/notify/kodi.py b/homeassistant/components/notify/kodi.py index eda01c130869f..3eb492f7fa63f 100644 --- a/homeassistant/components/notify/kodi.py +++ b/homeassistant/components/notify/kodi.py @@ -51,9 +51,9 @@ def async_get_service(hass, config, discovery_info=None): encryption = config.get(CONF_PROXY_SSL) if host.startswith('http://') or host.startswith('https://'): - host = host.lstrip('http://').lstrip('https://') + host = host[host.index('://') + 3:] _LOGGER.warning( - "Kodi host name should no longer conatin http:// See updated " + "Kodi host name should no longer contain http:// See updated " "definitions here: " "https://home-assistant.io/components/media_player.kodi/") diff --git a/homeassistant/components/notify/lametric.py b/homeassistant/components/notify/lametric.py index a3af1eb191414..f6c3e152b0a3f 100644 --- a/homeassistant/components/notify/lametric.py +++ b/homeassistant/components/notify/lametric.py @@ -6,6 +6,7 @@ """ import logging +from requests.exceptions import ConnectionError as RequestsConnectionError import voluptuous as vol from homeassistant.components.notify import ( @@ -13,50 +14,66 @@ from homeassistant.const import CONF_ICON import homeassistant.helpers.config_validation as cv -from homeassistant.components.lametric import DOMAIN +from homeassistant.components.lametric import DOMAIN as LAMETRIC_DOMAIN REQUIREMENTS = ['lmnotify==0.0.4'] +DEPENDENCIES = ['lametric'] _LOGGER = logging.getLogger(__name__) -CONF_DISPLAY_TIME = "display_time" +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_DISPLAY_TIME, default=10): cv.positive_int, + 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) }) # pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): - """Get the Slack notification service.""" - hlmn = hass.data.get(DOMAIN) + """Get the LaMetric notification service.""" + hlmn = hass.data.get(LAMETRIC_DOMAIN) return LaMetricNotificationService(hlmn, config[CONF_ICON], - config[CONF_DISPLAY_TIME] * 1000) + config[CONF_LIFETIME] * 1000, + config[CONF_CYCLES], + config[CONF_PRIORITY]) class LaMetricNotificationService(BaseNotificationService): """Implement the notification service for LaMetric.""" - def __init__(self, hasslametricmanager, icon, display_time): + def __init__(self, hasslametricmanager, icon, lifetime, cycles, priority): """Initialize the service.""" self.hasslametricmanager = hasslametricmanager self._icon = icon - self._display_time = display_time + self._lifetime = lifetime + self._cycles = cycles + self._priority = priority + self._devices = [] # pylint: disable=broad-except def send_message(self, message="", **kwargs): - """Send a message to some LaMetric deviced.""" + """Send a message to some LaMetric device.""" from lmnotify import SimpleFrame, Sound, Model + from oauthlib.oauth2 import TokenExpiredError targets = kwargs.get(ATTR_TARGET) data = kwargs.get(ATTR_DATA) _LOGGER.debug("Targets/Data: %s/%s", targets, data) icon = self._icon + cycles = self._cycles sound = None + priority = self._priority - # User-defined icon? + # Additional data? if data is not None: if "icon" in data: icon = data["icon"] @@ -69,23 +86,40 @@ def send_message(self, message="", **kwargs): 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/Duration: %s, %s, %d", - icon, message, self._display_time) + _LOGGER.debug("Icon/Message/Cycles/Lifetime: %s, %s, %d, %d", + icon, message, self._cycles, self._lifetime) frames = [text_frame] - if sound is not None: - frames.append(sound) - - _LOGGER.debug(frames) - - model = Model(frames=frames) - lmn = self.hasslametricmanager.manager() - devices = lmn.get_devices() - for dev in devices: - if (targets is None) or (dev["name"] in targets): - lmn.set_device(dev) - lmn.send_notification(model, lifetime=self._display_time) - _LOGGER.debug("Sent notification to LaMetric %s", dev["name"]) + model = Model(frames=frames, cycles=cycles, sound=sound) + lmn = self.hasslametricmanager.manager + try: + self._devices = lmn.get_devices() + except TokenExpiredError: + _LOGGER.debug("Token expired, fetching new token") + lmn.get_token() + 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, + priority=priority) + _LOGGER.debug("Sent notification to LaMetric %s", + dev["name"]) + except OSError: + _LOGGER.warning("Cannot connect to LaMetric %s", + dev["name"]) diff --git a/homeassistant/components/notify/llamalab_automate.py b/homeassistant/components/notify/llamalab_automate.py index 606c0fafc8b80..0ddcb450bcf7d 100644 --- a/homeassistant/components/notify/llamalab_automate.py +++ b/homeassistant/components/notify/llamalab_automate.py @@ -56,4 +56,4 @@ def send_message(self, message="", **kwargs): response = requests.post(_RESOURCE, json=data) if response.status_code != 200: - _LOGGER.error("Error sending message: " + str(response)) + _LOGGER.error("Error sending message: %s", response) diff --git a/homeassistant/components/notify/mastodon.py b/homeassistant/components/notify/mastodon.py new file mode 100644 index 0000000000000..3ba95407fec15 --- /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 c3bdeae028020..fc29ad91dc915 100644 --- a/homeassistant/components/notify/matrix.py +++ b/homeassistant/components/notify/matrix.py @@ -5,191 +5,46 @@ https://home-assistant.io/components/notify.matrix/ """ import logging -import json -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 - -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() - - 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: - with open(self.session_filepath) as handle: - data = json.load(handle) - - 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 - - try: - with open(self.session_filepath, 'w') as handle: - handle.write(json.dumps(self.auth_tokens)) - - # Not saving the tokens to disk should not stop the client, we can just - # login using the password every time. - except (OSError, IOError, PermissionError) as ex: - _LOGGER.warning( - "Storing authentication tokens to file '%s' failed: %s", - self.session_filepath, str(ex)) + def __init__(self, default_room): + """Set up the notification service.""" + self._default_room = default_room - 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] - - 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) + target_rooms = kwargs.get(ATTR_TARGET) or [self._default_room] - _LOGGER.debug(room.send_text(message)) + service_data = { + ATTR_TARGET: target_rooms, + ATTR_MESSAGE: 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 8ae697048f501..1374779c5f041 100644 --- a/homeassistant/components/notify/mysensors.py +++ b/homeassistant/components/notify/mysensors.py @@ -9,12 +9,12 @@ 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 @@ def __init__(self, hass): """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/nfandroidtv.py b/homeassistant/components/notify/nfandroidtv.py index 6c4f7e49ddea6..1fa8f1dab78b6 100644 --- a/homeassistant/components/notify/nfandroidtv.py +++ b/homeassistant/components/notify/nfandroidtv.py @@ -4,8 +4,9 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.nfandroidtv/ """ -import os import logging +import io +import base64 import requests import voluptuous as vol @@ -31,6 +32,9 @@ DEFAULT_COLOR = 'grey' DEFAULT_INTERRUPT = False DEFAULT_TIMEOUT = 5 +DEFAULT_ICON = ( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGP6zwAAAgcBApo' + 'cMXEAAAAASUVORK5CYII=') ATTR_DURATION = 'duration' ATTR_POSITION = 'position' @@ -110,16 +114,13 @@ def __init__(self, remoteip, duration, position, transparency, color, self._default_color = color self._default_interrupt = interrupt self._timeout = timeout - self._icon_file = os.path.join( - os.path.dirname(__file__), '..', 'frontend', 'www_static', 'icons', - 'favicon-192x192.png') + self._icon_file = io.BytesIO(base64.b64decode(DEFAULT_ICON)) def send_message(self, message="", **kwargs): """Send a message to a Android TV device.""" _LOGGER.debug("Sending notification to: %s", self._target) - payload = dict(filename=('icon.png', - open(self._icon_file, 'rb'), + payload = dict(filename=('icon.png', self._icon_file, 'application/octet-stream', {'Expires': '0'}), type='0', title=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), @@ -129,7 +130,7 @@ def send_message(self, message="", **kwargs): transparency='%i' % TRANSPARENCIES.get( self._default_transparency), offset='0', app=ATTR_TITLE_DEFAULT, force='true', - interrupt='%i' % self._default_interrupt) + interrupt='%i' % self._default_interrupt,) data = kwargs.get(ATTR_DATA) if data: diff --git a/homeassistant/components/notify/prowl.py b/homeassistant/components/notify/prowl.py index 1298657a69a44..3928fa81167b3 100644 --- a/homeassistant/components/notify/prowl.py +++ b/homeassistant/components/notify/prowl.py @@ -1,70 +1,70 @@ -""" -Prowl notification service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.prowl/ -""" -import logging -import asyncio - -import async_timeout -import voluptuous as vol - -from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, PLATFORM_SCHEMA, - BaseNotificationService) -from homeassistant.const import CONF_API_KEY -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -_LOGGER = logging.getLogger(__name__) -_RESOURCE = 'https://api.prowlapp.com/publicapi/' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, -}) - - -@asyncio.coroutine -def async_get_service(hass, config, discovery_info=None): - """Get the Prowl notification service.""" - return ProwlNotificationService(hass, config[CONF_API_KEY]) - - -class ProwlNotificationService(BaseNotificationService): - """Implement the notification service for Prowl.""" - - def __init__(self, hass, api_key): - """Initialize the service.""" - self._hass = hass - self._api_key = api_key - - @asyncio.coroutine - def async_send_message(self, message, **kwargs): - """Send the message to the user.""" - response = None - session = None - url = '{}{}'.format(_RESOURCE, 'add') - data = kwargs.get(ATTR_DATA) - payload = { - 'apikey': self._api_key, - 'application': 'Home-Assistant', - 'event': kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), - 'description': message, - 'priority': data['priority'] if data and 'priority' in data else 0 - } - - _LOGGER.debug("Attempting call Prowl service at %s", url) - session = async_get_clientsession(self._hass) - - try: - with async_timeout.timeout(10, loop=self._hass.loop): - response = yield from session.post(url, data=payload) - result = yield from response.text() - - if response.status != 200 or 'error' in result: - _LOGGER.error("Prowl service returned http " - "status %d, response %s", - response.status, result) - except asyncio.TimeoutError: - _LOGGER.error("Timeout accessing Prowl at %s", url) +""" +Prowl notification service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.prowl/ +""" +import logging +import asyncio + +import async_timeout +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, PLATFORM_SCHEMA, + BaseNotificationService) +from homeassistant.const import CONF_API_KEY +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = 'https://api.prowlapp.com/publicapi/' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, +}) + + +@asyncio.coroutine +def async_get_service(hass, config, discovery_info=None): + """Get the Prowl notification service.""" + return ProwlNotificationService(hass, config[CONF_API_KEY]) + + +class ProwlNotificationService(BaseNotificationService): + """Implement the notification service for Prowl.""" + + def __init__(self, hass, api_key): + """Initialize the service.""" + self._hass = hass + self._api_key = api_key + + @asyncio.coroutine + def async_send_message(self, message, **kwargs): + """Send the message to the user.""" + response = None + session = None + url = '{}{}'.format(_RESOURCE, 'add') + data = kwargs.get(ATTR_DATA) + payload = { + 'apikey': self._api_key, + 'application': 'Home-Assistant', + 'event': kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), + 'description': message, + 'priority': data['priority'] if data and 'priority' in data else 0 + } + + _LOGGER.debug("Attempting call Prowl service at %s", url) + session = async_get_clientsession(self._hass) + + try: + with async_timeout.timeout(10, loop=self._hass.loop): + response = yield from session.post(url, data=payload) + result = yield from response.text() + + if response.status != 200 or 'error' in result: + _LOGGER.error("Prowl service returned http " + "status %d, response %s", + response.status, result) + except asyncio.TimeoutError: + _LOGGER.error("Timeout accessing Prowl at %s", url) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index d8b6741352845..37edb6709a74d 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -10,8 +10,8 @@ import voluptuous as vol from homeassistant.components.notify import ( - ATTR_DATA, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA, BaseNotificationService) + ATTR_DATA, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, + BaseNotificationService) from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv @@ -22,6 +22,7 @@ ATTR_URL = 'url' ATTR_FILE = 'file' ATTR_FILE_URL = 'file_url' +ATTR_LIST = 'list' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, @@ -85,12 +86,12 @@ def send_message(self, message=None, **kwargs): refreshed = False if not targets: - # Backward compatibility, notify all devices in own account + # Backward compatibility, notify all devices in own account. self._push_data(message, title, data, self.pushbullet) _LOGGER.info("Sent notification to self") return - # Main loop, process all targets specified + # Main loop, process all targets specified. for target in targets: try: ttype, tname = target.split('/', 1) @@ -98,15 +99,15 @@ def send_message(self, message=None, **kwargs): _LOGGER.error("Invalid target syntax: %s", target) continue - # Target is email, send directly, don't use a target object - # This also seems works to send to all devices in own account + # Target is email, send directly, don't use a target object. + # This also seems to work to send to all devices in own account. if ttype == 'email': self._push_data(message, title, data, self.pushbullet, tname) _LOGGER.info("Sent notification to email %s", tname) continue # Refresh if name not found. While awaiting periodic refresh - # solution in component, poor mans refresh ;) + # solution in component, poor mans refresh. if ttype not in self.pbtargets: _LOGGER.error("Invalid target syntax: %s", target) continue @@ -127,40 +128,45 @@ def send_message(self, message=None, **kwargs): _LOGGER.error("No such target: %s/%s", ttype, tname) continue - def _push_data(self, message, title, data, pusher, tname=None): + def _push_data(self, message, title, data, pusher, email=None): + """Create the message content.""" from pushbullet import PushError if data is None: data = {} + data_list = data.get(ATTR_LIST) url = data.get(ATTR_URL) filepath = data.get(ATTR_FILE) file_url = data.get(ATTR_FILE_URL) try: + email_kwargs = {} + if email: + email_kwargs['email'] = email if url: - if tname: - pusher.push_link(title, url, body=message, email=tname) - else: - pusher.push_link(title, url, body=message) + pusher.push_link(title, url, body=message, **email_kwargs) elif filepath: if not self.hass.config.is_allowed_path(filepath): - _LOGGER.error("Filepath is not valid or allowed.") + _LOGGER.error("Filepath is not valid or allowed") return - with open(filepath, "rb") as fileh: + with open(filepath, 'rb') as fileh: filedata = self.pushbullet.upload_file(fileh, filepath) if filedata.get('file_type') == 'application/x-empty': - _LOGGER.error("Can not send an empty file.") + _LOGGER.error("Can not send an empty file") return - pusher.push_file(title=title, body=message, **filedata) + filedata.update(email_kwargs) + pusher.push_file(title=title, body=message, + **filedata) elif file_url: if not file_url.startswith('http'): - _LOGGER.error("Url should start with http or https.") + _LOGGER.error("URL should start with http or https") return - pusher.push_file(title=title, body=message, file_name=file_url, - file_url=file_url, - file_type=mimetypes.guess_type(file_url)[0]) + pusher.push_file(title=title, body=message, + file_name=file_url, file_url=file_url, + file_type=(mimetypes + .guess_type(file_url)[0]), + **email_kwargs) + elif data_list: + pusher.push_list(title, data_list, **email_kwargs) else: - if tname: - pusher.push_note(title, message, email=tname) - else: - pusher.push_note(title, message) + pusher.push_note(title, message, **email_kwargs) except PushError as err: _LOGGER.error("Notify failed: %s", err) diff --git a/homeassistant/components/notify/pushsafer.py b/homeassistant/components/notify/pushsafer.py index 78a600ab8d6b0..30068854f2ea9 100644 --- a/homeassistant/components/notify/pushsafer.py +++ b/homeassistant/components/notify/pushsafer.py @@ -5,20 +5,41 @@ https://home-assistant.io/components/notify.pushsafer/ """ import logging +import base64 +import mimetypes import requests +from requests.auth import HTTPBasicAuth import voluptuous as vol from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) + ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_TARGET, ATTR_DATA, + PLATFORM_SCHEMA, BaseNotificationService) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://www.pushsafer.com/api' +_ALLOWED_IMAGES = ['image/gif', 'image/jpeg', 'image/png'] CONF_DEVICE_KEY = 'private_key' +CONF_TIMEOUT = 15 -DEFAULT_TIMEOUT = 10 +# Top level attributes in 'data' +ATTR_SOUND = 'sound' +ATTR_VIBRATION = 'vibration' +ATTR_ICON = 'icon' +ATTR_ICONCOLOR = 'iconcolor' +ATTR_URL = 'url' +ATTR_URLTITLE = 'urltitle' +ATTR_TIME2LIVE = 'time2live' +ATTR_PICTURE1 = 'picture1' + +# Attributes contained in picture1 +ATTR_PICTURE1_URL = 'url' +ATTR_PICTURE1_PATH = 'path' +ATTR_PICTURE1_USERNAME = 'username' +ATTR_PICTURE1_PASSWORD = 'password' +ATTR_PICTURE1_AUTH = 'auth' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DEVICE_KEY): cv.string, @@ -27,21 +48,118 @@ def get_service(hass, config, discovery_info=None): """Get the Pushsafer.com notification service.""" - return PushsaferNotificationService(config.get(CONF_DEVICE_KEY)) + return PushsaferNotificationService(config.get(CONF_DEVICE_KEY), + hass.config.is_allowed_path) class PushsaferNotificationService(BaseNotificationService): """Implementation of the notification service for Pushsafer.com.""" - def __init__(self, private_key): + def __init__(self, private_key, is_allowed_path): """Initialize the service.""" self._private_key = private_key + self.is_allowed_path = is_allowed_path def send_message(self, message='', **kwargs): - """Send a message to a user.""" + """Send a message to specified target.""" + if kwargs.get(ATTR_TARGET) is None: + targets = ["a"] + _LOGGER.debug("No target specified. Sending push to all") + else: + targets = kwargs.get(ATTR_TARGET) + _LOGGER.debug("%s target(s) specified", len(targets)) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - payload = {'k': self._private_key, 't': title, 'm': message} - response = requests.get(_RESOURCE, params=payload, - timeout=DEFAULT_TIMEOUT) - if response.status_code != 200: - _LOGGER.error("Not possible to send notification") + data = kwargs.get(ATTR_DATA, {}) + + # Converting the specified image to base64 + picture1 = data.get(ATTR_PICTURE1) + picture1_encoded = "" + if picture1 is not None: + _LOGGER.debug("picture1 is available") + url = picture1.get(ATTR_PICTURE1_URL, None) + local_path = picture1.get(ATTR_PICTURE1_PATH, None) + username = picture1.get(ATTR_PICTURE1_USERNAME) + password = picture1.get(ATTR_PICTURE1_PASSWORD) + auth = picture1.get(ATTR_PICTURE1_AUTH) + + if url is not None: + _LOGGER.debug("Loading image from url %s", url) + picture1_encoded = self.load_from_url(url, username, + password, auth) + elif local_path is not None: + _LOGGER.debug("Loading image from file %s", local_path) + picture1_encoded = self.load_from_file(local_path) + else: + _LOGGER.warning("missing url or local_path for picture1") + else: + _LOGGER.debug("picture1 is not specified") + + payload = { + 'k': self._private_key, + 't': title, + 'm': message, + 's': data.get(ATTR_SOUND, ""), + 'v': data.get(ATTR_VIBRATION, ""), + 'i': data.get(ATTR_ICON, ""), + 'c': data.get(ATTR_ICONCOLOR, ""), + 'u': data.get(ATTR_URL, ""), + 'ut': data.get(ATTR_URLTITLE, ""), + 'l': data.get(ATTR_TIME2LIVE, ""), + 'p': picture1_encoded + } + + for target in targets: + payload['d'] = target + response = requests.post(_RESOURCE, data=payload, + timeout=CONF_TIMEOUT) + if response.status_code != 200: + _LOGGER.error("Pushsafer failed with: %s", response.text) + else: + _LOGGER.debug("Push send: %s", response.json()) + + @classmethod + def get_base64(cls, filebyte, mimetype): + """Convert the image to the expected base64 string of pushsafer.""" + if mimetype not in _ALLOWED_IMAGES: + _LOGGER.warning("%s is a not supported mimetype for images", + mimetype) + return None + + base64_image = base64.b64encode(filebyte).decode('utf8') + return "data:{};base64,{}".format(mimetype, base64_image) + + def load_from_url(self, url=None, username=None, password=None, auth=None): + """Load image/document/etc from URL.""" + if url is not None: + _LOGGER.debug("Downloading image from %s", url) + if username is not None and password is not None: + auth_ = HTTPBasicAuth(username, password) + response = requests.get(url, auth=auth_, + timeout=CONF_TIMEOUT) + else: + response = requests.get(url, timeout=CONF_TIMEOUT) + return self.get_base64(response.content, + response.headers['content-type']) + else: + _LOGGER.warning("url not found in param") + + return None + + def load_from_file(self, local_path=None): + """Load image/document/etc from a local path.""" + try: + if local_path is not None: + _LOGGER.debug("Loading image from local path") + if self.is_allowed_path(local_path): + file_mimetype = mimetypes.guess_type(local_path) + _LOGGER.debug("Detected mimetype %s", file_mimetype) + with open(local_path, "rb") as binary_file: + data = binary_file.read() + return self.get_base64(data, file_mimetype[0]) + else: + _LOGGER.warning("Local path not found in params!") + except OSError as error: + _LOGGER.error("Can't load from local path: %s", error) + + return None diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py index 19339a2c7ecc0..40b09dc3c72f7 100644 --- a/homeassistant/components/notify/rest.py +++ b/homeassistant/components/notify/rest.py @@ -12,7 +12,8 @@ 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' @@ -22,8 +23,6 @@ CONF_TITLE_PARAMETER_NAME = 'title_param_name' DEFAULT_MESSAGE_PARAM_NAME = 'message' DEFAULT_METHOD = 'GET' -DEFAULT_TARGET_PARAM_NAME = None -DEFAULT_TITLE_PARAM_NAME = None PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.url, @@ -31,15 +30,12 @@ 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, - default=DEFAULT_TARGET_PARAM_NAME): cv.string, - vol.Optional(CONF_TITLE_PARAMETER_NAME, - default=DEFAULT_TITLE_PARAM_NAME): cv.string, - vol.Optional(CONF_DATA, - default=None): dict, - vol.Optional(CONF_DATA_TEMPLATE, - default=None): {cv.match_all: cv.template_complex} + vol.Optional(CONF_TARGET_PARAMETER_NAME): cv.string, + vol.Optional(CONF_TITLE_PARAMETER_NAME): cv.string, + vol.Optional(CONF_DATA): dict, + vol.Optional(CONF_DATA_TEMPLATE): {cv.match_all: cv.template_complex} }) _LOGGER = logging.getLogger(__name__) @@ -49,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) @@ -56,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 @@ -105,11 +103,14 @@ def _data_template_creator(value): 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/rocketchat.py b/homeassistant/components/notify/rocketchat.py new file mode 100644 index 0000000000000..e9b481b1cf3c1 --- /dev/null +++ b/homeassistant/components/notify/rocketchat.py @@ -0,0 +1,73 @@ +""" +Rocket.Chat notification service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.rocketchat/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_URL, CONF_USERNAME, CONF_PASSWORD, CONF_ROOM) +from homeassistant.components.notify import ( + ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) + +REQUIREMENTS = ['rocketchat-API==0.6.1'] + +_LOGGER = logging.getLogger(__name__) + +# pylint: disable=no-value-for-parameter +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): vol.Url(), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_ROOM): cv.string, +}) + + +def get_service(hass, config, discovery_info=None): + """Return the notify service.""" + from rocketchat_API.APIExceptions.RocketExceptions import ( + RocketConnectionException, RocketAuthenticationException) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + url = config.get(CONF_URL) + room = config.get(CONF_ROOM) + + try: + return RocketChatNotificationService(url, username, password, room) + except RocketConnectionException: + _LOGGER.warning( + "Unable to connect to Rocket.Chat server at %s", url) + except RocketAuthenticationException: + _LOGGER.warning( + "Rocket.Chat authentication failed for user %s", username) + _LOGGER.info("Please check your username/password") + + return None + + +class RocketChatNotificationService(BaseNotificationService): + """Implement the notification service for Rocket.Chat.""" + + def __init__(self, url, username, password, room): + """Initialize the service.""" + from rocketchat_API.rocketchat import RocketChat + self._room = room + self._server = RocketChat(username, password, server_url=url) + + def send_message(self, message="", **kwargs): + """Send a message to Rocket.Chat.""" + data = kwargs.get(ATTR_DATA) or {} + resp = self._server.chat_post_message( + message, channel=self._room, **data) + if resp.status_code == 200: + success = resp.json()["success"] + if not success: + _LOGGER.error("Unable to post Rocket.Chat message") + else: + _LOGGER.error("Incorrect status code when posting message: %d", + resp.status_code) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index b7f192ff9834a..89117397a5336 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -10,10 +10,11 @@ from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) +from homeassistant.const import ( + CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT, CONTENT_TYPE_TEXT_PLAIN) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==5.2.0'] +REQUIREMENTS = ['sendgrid==5.3.0'] _LOGGER = logging.getLogger(__name__) @@ -67,7 +68,7 @@ def send_message(self, message='', **kwargs): }, "content": [ { - "type": "text/plain", + "type": CONTENT_TYPE_TEXT_PLAIN, "value": message } ] diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml index 4fe66844aa93a..23b1c968c4a39 100644 --- a/homeassistant/components/notify/services.yaml +++ b/homeassistant/components/notify/services.yaml @@ -1,31 +1,27 @@ -notify: - description: Send a notification +# Describes the format for available notification services +notify: + description: Send a notification. fields: message: description: Message body of the notification. example: The garage door has been open for 10 minutes. - title: description: Optional title for your notification. example: 'Your Garage Door Friend' - target: description: An array of targets to send the notification to. Optional depending on the platform. example: platform specific - data: description: Extended information for notification. Optional depending on the platform. example: platform specific apns_register: description: Registers a device to receive push notifications. - fields: push_id: description: The device token, a 64 character hex string (256 bits). The device token is provided to you by your client app, which receives the token after registering itself with the remote notification service. example: '72f2a8633655c5ce574fdc9b2b34ff8abdfc3b739b6ceb7a9ff06c1cbbf99f62' - name: description: A friendly name for the device (optional). example: 'Sam''s iPhone' diff --git a/homeassistant/components/notify/simplepush.py b/homeassistant/components/notify/simplepush.py deleted file mode 100644 index cda6a6952e0df..0000000000000 --- a/homeassistant/components/notify/simplepush.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Simplepush notification service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.simplepush/ -""" -import logging - -import requests -import voluptuous as vol - -from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) -_RESOURCE = 'https://api.simplepush.io/send' - -CONF_DEVICE_KEY = 'device_key' - -DEFAULT_TIMEOUT = 10 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICE_KEY): cv.string, -}) - - -def get_service(hass, config, discovery_info=None): - """Get the Simplepush notification service.""" - return SimplePushNotificationService(config.get(CONF_DEVICE_KEY)) - - -class SimplePushNotificationService(BaseNotificationService): - """Implementation of the notification service for SimplePush.""" - - def __init__(self, device_key): - """Initialize the service.""" - self._device_key = device_key - - def send_message(self, message='', **kwargs): - """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - - # Upstream bug will be fixed soon, but no dead-line available. - # payload = 'key={}&title={}&msg={}'.format( - # self._device_key, title, message).replace(' ', '%') - # response = requests.get( - # _RESOURCE, data=payload, timeout=DEFAULT_TIMEOUT) - response = requests.get( - '{}/{}/{}/{}'.format(_RESOURCE, self._device_key, title, message), - timeout=DEFAULT_TIMEOUT) - - if response.json()['status'] != 'OK': - _LOGGER.error("Not possible to send notification") diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index 30aadfc8297c9..b50260e4c613b 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -17,7 +17,7 @@ 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 0000000000000..f31e50a588683 --- /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/synology_chat.py b/homeassistant/components/notify/synology_chat.py new file mode 100644 index 0000000000000..8b968729074eb --- /dev/null +++ b/homeassistant/components/notify/synology_chat.py @@ -0,0 +1,53 @@ +""" +SynologyChat platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.synology_chat/ +""" +import logging +import json + +import requests +import voluptuous as vol + +from homeassistant.components.notify import ( + BaseNotificationService, PLATFORM_SCHEMA) +from homeassistant.const import CONF_RESOURCE +import homeassistant.helpers.config_validation as cv + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_RESOURCE): cv.url, +}) + +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config, discovery_info=None): + """Get the Synology Chat notification service.""" + resource = config.get(CONF_RESOURCE) + + return SynologyChatNotificationService(resource) + + +class SynologyChatNotificationService(BaseNotificationService): + """Implementation of a notification service for Synology Chat.""" + + def __init__(self, resource): + """Initialize the service.""" + self._resource = resource + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + data = { + 'text': message + } + + to_send = 'payload={}'.format(json.dumps(data)) + + response = requests.post(self._resource, data=to_send, timeout=10) + + if response.status_code not in (200, 201): + _LOGGER.exception( + "Error sending message. Response %d: %s:", + response.status_code, response.reason) diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index fb453263dd854..899ccf9b09af9 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -21,6 +21,7 @@ ATTR_KEYBOARD = 'keyboard' ATTR_INLINE_KEYBOARD = 'inline_keyboard' ATTR_PHOTO = 'photo' +ATTR_VIDEO = 'video' ATTR_DOCUMENT = 'document' CONF_CHAT_ID = 'chat_id' @@ -63,7 +64,7 @@ def send_message(self, message="", **kwargs): keys = keys if isinstance(keys, list) else [keys] service_data.update(inline_keyboard=keys) - # Send a photo, a document or a location + # Send a photo, video, document, or location if data is not None and ATTR_PHOTO in data: photos = data.get(ATTR_PHOTO, None) photos = photos if isinstance(photos, list) else [photos] @@ -72,6 +73,14 @@ def send_message(self, message="", **kwargs): self.hass.services.call( DOMAIN, 'send_photo', service_data=service_data) return + elif data is not None and ATTR_VIDEO in data: + videos = data.get(ATTR_VIDEO, None) + videos = videos if isinstance(videos, list) else [videos] + for video_data in videos: + service_data.update(video_data) + self.hass.services.call( + DOMAIN, 'send_video', service_data=service_data) + return elif data is not None and ATTR_LOCATION in data: service_data.update(data.get(ATTR_LOCATION)) return self.hass.services.call( diff --git a/homeassistant/components/notify/telstra.py b/homeassistant/components/notify/telstra.py index 7fabb51eac856..82ac914a647cb 100644 --- a/homeassistant/components/notify/telstra.py +++ b/homeassistant/components/notify/telstra.py @@ -6,12 +6,13 @@ """ import logging +from aiohttp.hdrs import CONTENT_TYPE, AUTHORIZATION import requests import voluptuous as vol from homeassistant.components.notify import ( - BaseNotificationService, ATTR_TITLE, PLATFORM_SCHEMA) -from homeassistant.const import CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE + ATTR_TITLE, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONTENT_TYPE_JSON import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -73,8 +74,8 @@ def send_message(self, message="", **kwargs): } message_resource = 'https://api.telstra.com/v1/sms/messages' message_headers = { - HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON, - 'Authorization': 'Bearer ' + token_response['access_token'], + CONTENT_TYPE: CONTENT_TYPE_JSON, + AUTHORIZATION: 'Bearer {}'.format(token_response['access_token']), } message_response = requests.post( message_resource, headers=message_headers, json=message_data, diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 25e6fc00a2f7e..9489e05cfa5d2 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -19,7 +19,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME from homeassistant.helpers.event import async_track_point_in_time -REQUIREMENTS = ['TwitterAPI==2.4.6'] +REQUIREMENTS = ['TwitterAPI==2.5.0'] _LOGGER = logging.getLogger(__name__) @@ -75,7 +75,7 @@ def send_message(self, message="", **kwargs): self.upload_media_then_callback(callback, media) - def send_message_callback(self, message, media_id): + def send_message_callback(self, message, media_id=None): """Tweet a message, optionally with media.""" if self.user: resp = self.api.request('direct_messages/new', @@ -95,7 +95,7 @@ def send_message_callback(self, message, media_id): def upload_media_then_callback(self, callback, media_path=None): """Upload media.""" if not media_path: - return None + return callback() with open(media_path, 'rb') as file: total_bytes = os.path.getsize(media_path) @@ -116,6 +116,9 @@ def upload_media_then_callback(self, callback, media_path=None): self.log_error_resp(resp) return None + if resp.json().get('processing_info') is None: + return callback(media_id) + self.check_status_until_done(media_id, callback) def media_info(self, media_path): diff --git a/homeassistant/components/notify/webostv.py b/homeassistant/components/notify/webostv.py index c70b198a333a2..78c43c5f0ad64 100644 --- a/homeassistant/components/notify/webostv.py +++ b/homeassistant/components/notify/webostv.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/notify.webostv/ """ import logging -import os import voluptuous as vol @@ -19,14 +18,11 @@ _LOGGER = logging.getLogger(__name__) WEBOSTV_CONFIG_FILE = 'webostv.conf' -HOME_ASSISTANT_ICON_PATH = os.path.join(os.path.dirname(__file__), '..', - 'frontend', 'www_static', 'icons', - 'favicon-1024x1024.png') PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_FILENAME, default=WEBOSTV_CONFIG_FILE): cv.string, - vol.Optional(CONF_ICON, default=HOME_ASSISTANT_ICON_PATH): cv.string + vol.Optional(CONF_ICON): cv.string }) @@ -36,7 +32,8 @@ def get_service(hass, config, discovery_info=None): from pylgtv import PyLGTVPairException path = hass.config.path(config.get(CONF_FILENAME)) - client = WebOsClient(config.get(CONF_HOST), key_file_path=path) + client = WebOsClient(config.get(CONF_HOST), key_file_path=path, + timeout_connect=8) if not client.is_registered(): try: diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index f93e1b8f42657..12ddf49fca8bd 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -11,12 +11,13 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT +from homeassistant.const import ( + CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT, CONF_ROOM) REQUIREMENTS = ['sleekxmpp==1.3.2', 'dnspython3==1.15.0', - 'pyasn1==0.3.3', - 'pyasn1-modules==0.1.1'] + 'pyasn1==0.3.7', + 'pyasn1-modules==0.1.5'] _LOGGER = logging.getLogger(__name__) @@ -29,6 +30,7 @@ vol.Required(CONF_RECIPIENT): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, vol.Optional(CONF_VERIFY, default=True): cv.boolean, + vol.Optional(CONF_ROOM, default=''): cv.string, }) @@ -37,31 +39,33 @@ def get_service(hass, config, discovery_info=None): return XmppNotificationService( config.get(CONF_SENDER), config.get(CONF_PASSWORD), config.get(CONF_RECIPIENT), config.get(CONF_TLS), - config.get(CONF_VERIFY)) + config.get(CONF_VERIFY), config.get(CONF_ROOM)) class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" - def __init__(self, sender, password, recipient, tls, verify): + def __init__(self, sender, password, recipient, tls, verify, room): """Initialize the service.""" self._sender = sender self._password = password self._recipient = recipient self._tls = tls self._verify = verify + self._room = room def send_message(self, message="", **kwargs): """Send a message to a user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = '{}: {}'.format(title, message) if title else message - send_message('{}/home-assistant'.format(self._sender), self._password, - self._recipient, self._tls, self._verify, data) + send_message('{}/home-assistant'.format(self._sender), + self._password, self._recipient, self._tls, + self._verify, self._room, data) def send_message(sender, password, recipient, use_tls, - verify_certificate, message): + verify_certificate, room, message): """Send a message over XMPP.""" import sleekxmpp @@ -72,12 +76,12 @@ def __init__(self): """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) self.add_event_handler('session_start', self.start) + if room: + self.register_plugin('xep_0045') # MUC if not verify_certificate: self.add_event_handler('ssl_invalid_cert', self.discard_ssl_invalid_cert) @@ -89,7 +93,13 @@ def start(self, event): """Start the communication and sends the message.""" self.send_presence() self.get_roster() - self.send_message(mto=recipient, mbody=message, mtype='chat') + + if room: + _LOGGER.debug("Joining room %s.", room) + self.plugin['xep_0045'].joinMUC(room, sender, wait=True) + self.send_message(mto=room, mbody=message, mtype='groupchat') + else: + self.send_message(mto=recipient, mbody=message, mtype='chat') self.disconnect(wait=True) def check_credentials(self, event): diff --git a/homeassistant/components/notify/yessssms.py b/homeassistant/components/notify/yessssms.py new file mode 100644 index 0000000000000..37a6a90a62ed6 --- /dev/null +++ b/homeassistant/components/notify/yessssms.py @@ -0,0 +1,51 @@ +""" +Support for the YesssSMS platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.yessssms/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_RECIPIENT +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['YesssSMS==0.1.1b3'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_RECIPIENT): cv.string, +}) + + +def get_service(hass, config, discovery_info=None): + """Get the YesssSMS notification service.""" + return YesssSMSNotificationService( + config[CONF_USERNAME], config[CONF_PASSWORD], config[CONF_RECIPIENT]) + + +class YesssSMSNotificationService(BaseNotificationService): + """Implement a notification service for the YesssSMS service.""" + + def __init__(self, username, password, recipient): + """Initialize the service.""" + from YesssSMS import YesssSMS + self.yesss = YesssSMS(username, password) + self._recipient = recipient + + def send_message(self, message="", **kwargs): + """Send a SMS message via Yesss.at's website.""" + try: + self.yesss.send(self._recipient, message) + except ValueError as ex: + if str(ex).startswith("YesssSMS:"): + _LOGGER.error(str(ex)) + except RuntimeError as ex: + if str(ex).startswith("YesssSMS:"): + _LOGGER.error(str(ex)) diff --git a/homeassistant/components/nuheat.py b/homeassistant/components/nuheat.py new file mode 100644 index 0000000000000..fb14f119dbde5 --- /dev/null +++ b/homeassistant/components/nuheat.py @@ -0,0 +1,45 @@ +""" +Support for NuHeat thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/nuheat/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_DEVICES +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery + +REQUIREMENTS = ["nuheat==0.3.0"] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "nuheat" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_DEVICES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the NuHeat thermostat component.""" + import nuheat + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + devices = conf.get(CONF_DEVICES) + + api = nuheat.NuHeat(username, password) + api.authenticate() + hass.data[DOMAIN] = (api, devices) + + discovery.load_platform(hass, "climate", DOMAIN, {}, config) + return True diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index fdf237d7180ff..5caaa1b372d70 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -9,6 +9,7 @@ import requests import voluptuous as vol +from aiohttp.hdrs import CONTENT_TYPE from homeassistant.const import CONF_API_KEY, CONF_HOST, CONTENT_TYPE_JSON import homeassistant.helpers.config_validation as cv @@ -55,8 +56,10 @@ class OctoPrintAPI(object): def __init__(self, api_url, key, bed, number_of_tools): """Initialize OctoPrint API and set headers needed later.""" self.api_url = api_url - self.headers = {'content-type': CONTENT_TYPE_JSON, - 'X-Api-Key': key} + self.headers = { + CONTENT_TYPE: CONTENT_TYPE_JSON, + 'X-Api-Key': key, + } self.printer_last_reading = [{}, None] self.job_last_reading = [{}, None] self.job_available = False @@ -66,7 +69,6 @@ def __init__(self, api_url, key, bed, number_of_tools): self.job_error_logged = False self.bed = bed self.number_of_tools = number_of_tools - _LOGGER.error(str(bed) + " " + str(number_of_tools)) def get_tools(self): """Get the list of tools that temperature is monitored on.""" @@ -115,9 +117,7 @@ def get(self, endpoint): self.job_error_logged = False self.printer_error_logged = False return response.json() - except (requests.exceptions.ConnectionError, - requests.exceptions.HTTPError, - requests.exceptions.ReadTimeout) as conn_exc: + except Exception as conn_exc: # pylint: disable=broad-except log_string = "Failed to update OctoPrint status. " + \ " Error: %s" % (conn_exc) # Only log the first failure diff --git a/homeassistant/components/panel_custom.py b/homeassistant/components/panel_custom.py index 7806cc4cac8bc..473d44f3b5515 100644 --- a/homeassistant/components/panel_custom.py +++ b/homeassistant/components/panel_custom.py @@ -4,13 +4,13 @@ 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 import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.components.frontend import register_panel DOMAIN = 'panel_custom' DEPENDENCIES = ['frontend'] @@ -40,7 +40,8 @@ _LOGGER = logging.getLogger(__name__) -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Initialize custom panel.""" success = False @@ -56,11 +57,11 @@ def setup(hass, config): name, panel_path) continue - register_panel( - hass, name, panel_path, + yield from hass.components.frontend.async_register_panel( + name, panel_path, sidebar_title=panel.get(CONF_SIDEBAR_TITLE), sidebar_icon=panel.get(CONF_SIDEBAR_ICON), - url_path=panel.get(CONF_URL_PATH), + frontend_url_path=panel.get(CONF_URL_PATH), config=panel.get(CONF_CONFIG), ) diff --git a/homeassistant/components/panel_iframe.py b/homeassistant/components/panel_iframe.py index 50e764ba1f92c..4574437bac94e 100644 --- a/homeassistant/components/panel_iframe.py +++ b/homeassistant/components/panel_iframe.py @@ -4,33 +4,42 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/panel_iframe/ """ +import asyncio + import voluptuous as vol +from homeassistant.const import (CONF_ICON, CONF_URL) import homeassistant.helpers.config_validation as cv -from homeassistant.components.frontend import register_built_in_panel -DOMAIN = 'panel_iframe' DEPENDENCIES = ['frontend'] +DOMAIN = 'panel_iframe' + CONF_TITLE = 'title' -CONF_ICON = 'icon' -CONF_URL = 'url' + +CONF_RELATIVE_URL_ERROR_MSG = "Invalid relative URL. Absolute path required." +CONF_RELATIVE_URL_REGEX = r'\A/' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ cv.slug: { + # pylint: disable=no-value-for-parameter vol.Optional(CONF_TITLE): cv.string, vol.Optional(CONF_ICON): cv.icon, - # pylint: disable=no-value-for-parameter - vol.Required(CONF_URL): vol.Url(), + vol.Required(CONF_URL): vol.Any( + vol.Match( + CONF_RELATIVE_URL_REGEX, + msg=CONF_RELATIVE_URL_ERROR_MSG), + vol.Url()), }})}, extra=vol.ALLOW_EXTRA) +@asyncio.coroutine def setup(hass, config): """Set up the iFrame frontend panels.""" for url_path, info in config[DOMAIN].items(): - register_built_in_panel( - hass, 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), + yield from hass.components.frontend.async_register_built_in_panel( + 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), url_path, {'url': info[CONF_URL]}) return True diff --git a/homeassistant/components/persistent_notification.py b/homeassistant/components/persistent_notification/__init__.py similarity index 90% rename from homeassistant/components/persistent_notification.py rename to homeassistant/components/persistent_notification/__init__.py index 5e68aeee3abc7..cce3550d35c8f 100644 --- a/homeassistant/components/persistent_notification.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/persistent_notification/ """ import asyncio -import os import logging import voluptuous as vol @@ -16,7 +15,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.util import slugify -from homeassistant.config import load_yaml_config_file ATTR_MESSAGE = 'message' ATTR_NOTIFICATION_ID = 'notification_id' @@ -43,6 +41,8 @@ DEFAULT_OBJECT_ID = 'notification' _LOGGER = logging.getLogger(__name__) +STATE = 'notifying' + @bind_hass def create(hass, message, title=None, notification_id=None): @@ -113,7 +113,9 @@ def create_service(call): _LOGGER.error('Error rendering message %s: %s', message, ex) message = message.template - hass.states.async_set(entity_id, message, attr) + attr[ATTR_MESSAGE] = message + + hass.states.async_set(entity_id, STATE, attr) @callback def dismiss_service(call): @@ -123,17 +125,10 @@ def dismiss_service(call): hass.states.async_remove(entity_id) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) - hass.services.async_register(DOMAIN, SERVICE_CREATE, create_service, - descriptions[DOMAIN][SERVICE_CREATE], SCHEMA_SERVICE_CREATE) hass.services.async_register(DOMAIN, SERVICE_DISMISS, dismiss_service, - descriptions[DOMAIN][SERVICE_DISMISS], SCHEMA_SERVICE_DISMISS) return True diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml new file mode 100644 index 0000000000000..ca73c6d56bb5e --- /dev/null +++ b/homeassistant/components/persistent_notification/services.yaml @@ -0,0 +1,19 @@ +create: + description: Show a notification in the frontend. + fields: + message: + description: Message body of the notification. [Templates accepted] + example: Please check your configuration.yaml. + title: + description: Optional title for your notification. [Optional, Templates accepted] + example: Test notification + notification_id: + description: Target ID of the notification, will replace a notification with the same Id. [Optional] + example: 1234 + +dismiss: + description: Remove a notification from the frontend. + fields: + notification_id: + description: Target ID of the notification, which should be removed. [Required] + example: 1234 diff --git a/homeassistant/components/pilight.py b/homeassistant/components/pilight.py index 3000820d28c4f..344c750c0ec74 100644 --- a/homeassistant/components/pilight.py +++ b/homeassistant/components/pilight.py @@ -26,7 +26,7 @@ CONF_SEND_DELAY = 'send_delay' DEFAULT_HOST = '127.0.0.1' -DEFAULT_PORT = 5000 +DEFAULT_PORT = 5001 DEFAULT_SEND_DELAY = 0.0 DOMAIN = 'pilight' @@ -130,7 +130,7 @@ class CallRateDelayThrottle(object): it should not block the mainloop. """ - def __init__(self, hass, delay_seconds: float): + def __init__(self, hass, delay_seconds: float) -> None: """Initialize the delay handler.""" self._delay = timedelta(seconds=max(0.0, delay_seconds)) self._queue = [] diff --git a/homeassistant/components/plant.py b/homeassistant/components/plant.py index 9b9e11e0fbb0b..048851e97f542 100644 --- a/homeassistant/components/plant.py +++ b/homeassistant/components/plant.py @@ -1,23 +1,25 @@ -""" -Component to monitor plants. +"""Component to monitor plants. For more details about this component, please refer to the documentation at https://home-assistant.io/components/plant/ """ import logging import asyncio - +from datetime import datetime, timedelta +from collections import deque import voluptuous as vol +from homeassistant.exceptions import HomeAssistantError from homeassistant.const import ( STATE_OK, STATE_PROBLEM, STATE_UNKNOWN, TEMP_CELSIUS, ATTR_TEMPERATURE, - CONF_SENSORS, ATTR_UNIT_OF_MEASUREMENT, ATTR_ICON) + CONF_SENSORS, ATTR_UNIT_OF_MEASUREMENT) from homeassistant.components import group import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.core import callback from homeassistant.helpers.event import async_track_state_change +from homeassistant.components.recorder.util import session_scope, execute _LOGGER = logging.getLogger(__name__) @@ -30,7 +32,13 @@ READING_BRIGHTNESS = 'brightness' ATTR_PROBLEM = 'problem' +ATTR_SENSORS = 'sensors' PROBLEM_NONE = 'none' +ATTR_MAX_BRIGHTNESS_HISTORY = 'max_brightness' + +# we're not returning only one value, we're returning a dict here. So we need +# to have a separate literal for it to avoid confusion. +ATTR_DICT_OF_UNITS_OF_MEASUREMENT = 'unit_of_measurement_dict' CONF_MIN_BATTERY_LEVEL = 'min_' + READING_BATTERY CONF_MIN_TEMPERATURE = 'min_' + READING_TEMPERATURE @@ -41,6 +49,7 @@ CONF_MAX_CONDUCTIVITY = 'max_' + READING_CONDUCTIVITY CONF_MIN_BRIGHTNESS = 'min_' + READING_BRIGHTNESS CONF_MAX_BRIGHTNESS = 'max_' + READING_BRIGHTNESS +CONF_CHECK_DAYS = 'check_days' CONF_SENSOR_BATTERY_LEVEL = READING_BATTERY CONF_SENSOR_MOISTURE = READING_MOISTURE @@ -67,6 +76,7 @@ vol.Optional(CONF_MAX_CONDUCTIVITY): cv.positive_int, vol.Optional(CONF_MIN_BRIGHTNESS): cv.positive_int, vol.Optional(CONF_MAX_BRIGHTNESS): cv.positive_int, + vol.Optional(CONF_CHECK_DAYS): cv.positive_int, }) DOMAIN = 'plant' @@ -82,6 +92,11 @@ }, extra=vol.ALLOW_EXTRA) +# Flag for enabling/disabling the loading of the history from the database. +# This feature is turned off right now as its tests are not 100% stable. +ENABLE_LOAD_HISTORY = False + + @asyncio.coroutine def async_setup(hass, config): """Set up the Plant component.""" @@ -98,7 +113,6 @@ def async_setup(hass, config): entities.append(entity) yield from component.async_add_entities(entities) - return True @@ -113,31 +127,26 @@ class Plant(Entity): READING_BATTERY: { ATTR_UNIT_OF_MEASUREMENT: '%', 'min': CONF_MIN_BATTERY_LEVEL, - 'icon': 'mdi:battery-outline' }, READING_TEMPERATURE: { ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, 'min': CONF_MIN_TEMPERATURE, 'max': CONF_MAX_TEMPERATURE, - 'icon': 'mdi:thermometer' }, READING_MOISTURE: { ATTR_UNIT_OF_MEASUREMENT: '%', 'min': CONF_MIN_MOISTURE, 'max': CONF_MAX_MOISTURE, - 'icon': 'mdi:water' }, READING_CONDUCTIVITY: { ATTR_UNIT_OF_MEASUREMENT: 'µS/cm', 'min': CONF_MIN_CONDUCTIVITY, 'max': CONF_MAX_CONDUCTIVITY, - 'icon': 'mdi:emoticon-poop' }, READING_BRIGHTNESS: { ATTR_UNIT_OF_MEASUREMENT: 'lux', 'min': CONF_MIN_BRIGHTNESS, 'max': CONF_MAX_BRIGHTNESS, - 'icon': 'mdi:white-balance-sunny' } } @@ -145,8 +154,11 @@ def __init__(self, name, config): """Initialize the Plant component.""" self._config = config self._sensormap = dict() + self._readingmap = dict() + self._unit_of_measurement = dict() for reading, entity_id in config['sensors'].items(): self._sensormap[entity_id] = reading + self._readingmap[reading] = entity_id self._state = STATE_UNKNOWN self._name = name self._battery = None @@ -154,9 +166,13 @@ def __init__(self, name, config): self._conductivity = None self._temperature = None self._brightness = None - self._icon = 'mdi:help-circle' self._problems = PROBLEM_NONE + self._conf_check_days = 3 # default check interval: 3 days + if CONF_CHECK_DAYS in self._config: + self._conf_check_days = self._config[CONF_CHECK_DAYS] + self._brightness_history = DailyHistory(self._conf_check_days) + @callback def state_changed(self, entity_id, _, new_state): """Update the sensor status. @@ -171,18 +187,23 @@ def state_changed(self, entity_id, _, new_state): reading = self._sensormap[entity_id] if reading == READING_MOISTURE: - self._moisture = int(value) + self._moisture = int(float(value)) elif reading == READING_BATTERY: - self._battery = int(value) + self._battery = int(float(value)) elif reading == READING_TEMPERATURE: self._temperature = float(value) elif reading == READING_CONDUCTIVITY: - self._conductivity = int(value) + self._conductivity = int(float(value)) elif reading == READING_BRIGHTNESS: - self._brightness = int(value) + self._brightness = int(float(value)) + self._brightness_history.add_measurement(self._brightness, + new_state.last_updated) else: - raise _LOGGER.error("Unknown reading from sensor %s: %s", - entity_id, value) + raise HomeAssistantError( + "Unknown reading from sensor {}: {}".format(entity_id, value)) + if ATTR_UNIT_OF_MEASUREMENT in new_state.attributes: + self._unit_of_measurement[reading] = \ + new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) self._update_state() def _update_state(self): @@ -192,27 +213,79 @@ def _update_state(self): params = self.READINGS[sensor_name] value = getattr(self, '_{}'.format(sensor_name)) if value is not None: - if 'min' in params and params['min'] in self._config: - min_value = self._config[params['min']] - if value < min_value: - result.append('{} low'.format(sensor_name)) - self._icon = params['icon'] - - if 'max' in params and params['max'] in self._config: - max_value = self._config[params['max']] - if value > max_value: - result.append('{} high'.format(sensor_name)) - self._icon = params['icon'] + if sensor_name == READING_BRIGHTNESS: + result.append(self._check_min( + sensor_name, self._brightness_history.max, params)) + else: + result.append(self._check_min(sensor_name, value, params)) + result.append(self._check_max(sensor_name, value, params)) + + result = [r for r in result if r is not None] if result: self._state = STATE_PROBLEM - self._problems = ','.join(result) + self._problems = ', '.join(result) else: self._state = STATE_OK - self._icon = 'mdi:thumb-up' self._problems = PROBLEM_NONE _LOGGER.debug("New data processed") - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() + + def _check_min(self, sensor_name, value, params): + """If configured, check the value against the defined minimum value.""" + if 'min' in params and params['min'] in self._config: + min_value = self._config[params['min']] + if value < min_value: + return '{} low'.format(sensor_name) + + def _check_max(self, sensor_name, value, params): + """If configured, check the value against the defined maximum value.""" + if 'max' in params and params['max'] in self._config: + max_value = self._config[params['max']] + if value > max_value: + return '{} high'.format(sensor_name) + return None + + @asyncio.coroutine + def async_added_to_hass(self): + """After being added to hass, load from history.""" + if ENABLE_LOAD_HISTORY and 'recorder' in self.hass.config.components: + # only use the database if it's configured + self.hass.async_add_job(self._load_history_from_db) + + @asyncio.coroutine + def _load_history_from_db(self): + """Load the history of the brightness values from the database. + + This only needs to be done once during startup. + """ + from homeassistant.components.recorder.models import States + start_date = datetime.now() - timedelta(days=self._conf_check_days) + entity_id = self._readingmap.get(READING_BRIGHTNESS) + if entity_id is None: + _LOGGER.debug("not reading the history from the database as " + "there is no brightness sensor configured.") + return + + _LOGGER.debug("initializing values for %s from the database", + self._name) + with session_scope(hass=self.hass) as session: + query = session.query(States).filter( + (States.entity_id == entity_id.lower()) and + (States.last_updated > start_date) + ).order_by(States.last_updated.asc()) + states = execute(query) + + for state in states: + # filter out all None, NaN and "unknown" states + # only keep real values + try: + self._brightness_history.add_measurement( + int(state.state), state.last_updated) + except ValueError: + pass + _LOGGER.debug("initializing from database completed") + self.async_schedule_update_ha_state() @property def should_poll(self): @@ -237,11 +310,59 @@ def state_attributes(self): sensor in the attributes of the device. """ attrib = { - ATTR_ICON: self._icon, ATTR_PROBLEM: self._problems, + ATTR_SENSORS: self._readingmap, + ATTR_DICT_OF_UNITS_OF_MEASUREMENT: self._unit_of_measurement, } for reading in self._sensormap.values(): attrib[reading] = getattr(self, '_{}'.format(reading)) + if self._brightness_history.max is not None: + attrib[ATTR_MAX_BRIGHTNESS_HISTORY] = self._brightness_history.max + return attrib + + +class DailyHistory(object): + """Stores one measurement per day for a maximum number of days. + + At the moment only the maximum value per day is kept. + """ + + def __init__(self, max_length): + """Create new DailyHistory with a maximum length of the history.""" + self.max_length = max_length + self._days = None + self._max_dict = dict() + self.max = None + + def add_measurement(self, value, timestamp=None): + """Add a new measurement for a certain day.""" + day = (timestamp or datetime.now()).date() + if value is None: + return + if self._days is None: + self._days = deque() + self._add_day(day, value) + else: + current_day = self._days[-1] + if day == current_day: + self._max_dict[day] = max(value, self._max_dict[day]) + elif day > current_day: + self._add_day(day, value) + else: + _LOGGER.warning('received old measurement, not storing it!') + + self.max = max(self._max_dict.values()) + + def _add_day(self, day, value): + """Add a new day to the history. + + Deletes the oldest day, if the queue becomes too long. + """ + if len(self._days) == self.max_length: + oldest = self._days.popleft() + del self._max_dict[oldest] + self._days.append(day) + self._max_dict[day] = value diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index 0396cafd4ffaa..96ed098567d1d 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -13,13 +13,14 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components import recorder from homeassistant.const import ( - CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, TEMP_CELSIUS, - EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN) + CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, + EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN, + ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT) from homeassistant import core as hacore from homeassistant.helpers import state as state_helper from homeassistant.util.temperature import fahrenheit_to_celsius -REQUIREMENTS = ['prometheus_client==0.0.19'] +REQUIREMENTS = ['prometheus_client==0.1.0'] _LOGGER = logging.getLogger(__name__) @@ -85,9 +86,16 @@ def handle_event(self, event): 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] @@ -99,6 +107,7 @@ def _metric(self, metric, factory, documentation, labels=None): def _labels(state): return { 'entity': state.entity_id, + 'domain': state.domain, 'friendly_name': state.attributes.get('friendly_name'), } @@ -159,50 +168,50 @@ def _handle_lock(self, state): value = state_helper.state_as_number(state) metric.labels(**self._labels(state)).set(value) - def _handle_sensor(self, state): - _sensor_types = { - TEMP_CELSIUS: ( - 'temperature_c', self.prometheus_client.Gauge, - 'Temperature in degrees Celsius', - ), - TEMP_FAHRENHEIT: ( + def _handle_climate(self, state): + temp = state.attributes.get(ATTR_TEMPERATURE) + if temp: + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if unit == TEMP_FAHRENHEIT: + temp = fahrenheit_to_celsius(temp) + metric = self._metric( 'temperature_c', self.prometheus_client.Gauge, - 'Temperature in degrees Celsius', - ), - '%': ( - 'relative_humidity', self.prometheus_client.Gauge, - 'Relative humidity (0..100)', - ), - 'lux': ( - 'light_lux', self.prometheus_client.Gauge, - 'Light level in lux', - ), - 'kWh': ( - 'electricity_used_kwh', self.prometheus_client.Gauge, - 'Electricity used by this device in KWh', - ), - 'V': ( - 'voltage', self.prometheus_client.Gauge, - 'Currently reported voltage in Volts', - ), - 'W': ( - 'electricity_usage_w', self.prometheus_client.Gauge, - 'Currently reported electricity draw in Watts', - ), - } + 'Temperature in degrees Celsius') + metric.labels(**self._labels(state)).set(temp) + + metric = self._metric( + 'climate_state', self.prometheus_client.Gauge, + 'State of the thermostat (0/1)') + try: + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass - unit = state.attributes.get('unit_of_measurement') - metric = _sensor_types.get(unit) + def _handle_sensor(self, state): - if metric is not None: - metric = self._metric(*metric) - try: - value = state_helper.state_as_number(state) - if unit == TEMP_FAHRENHEIT: - value = fahrenheit_to_celsius(value) - metric.labels(**self._labels(state)).set(value) - except ValueError: - pass + 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]) + except ValueError: + pass + + _metric = self._metric(metric, self.prometheus_client.Gauge, + state.entity_id) + + try: + value = state_helper.state_as_number(state) + if unit == TEMP_FAHRENHEIT: + value = fahrenheit_to_celsius(value) + _metric.labels(**self._labels(state)).set(value) + except ValueError: + pass self._battery(state) @@ -212,12 +221,25 @@ def _handle_switch(self, state): self.prometheus_client.Gauge, 'State of the switch (0/1)', ) - value = state_helper.state_as_number(state) - metric.labels(**self._labels(state)).set(value) + + try: + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + except ValueError: + pass def _handle_zwave(self, state): self._battery(state) + def _handle_automation(self, state): + metric = self._metric( + 'automation_triggered_count', + self.prometheus_client.Counter, + 'Count of times an automation has been triggered', + ) + + metric.labels(**self._labels(state)).inc() + class PrometheusView(HomeAssistantView): """Handle Prometheus requests.""" diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index 386abba59aed2..1d33740d4a485 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -1,18 +1,30 @@ -"""Component to allow running Python scripts.""" +""" +Component to allow running Python scripts. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/python_script/ +""" +import datetime import glob -import os import logging +import os +import time import voluptuous as vol +from homeassistant.const import SERVICE_RELOAD from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.util import sanitize_filename +import homeassistant.util.dt as dt_util + +REQUIREMENTS = ['restrictedpython==4.0b3'] + +_LOGGER = logging.getLogger(__name__) DOMAIN = 'python_script' -REQUIREMENTS = ['restrictedpython==4.0a3'] + FOLDER = 'python_scripts' -_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema(dict) @@ -23,6 +35,13 @@ ALLOWED_STATEMACHINE = set(['entity_ids', 'all', 'get', 'is_state', 'is_state_attr', 'remove', 'set']) ALLOWED_SERVICEREGISTRY = set(['services', 'has_service', 'call']) +ALLOWED_TIME = set(['sleep', 'strftime', 'strptime', 'gmtime', 'localtime', + 'ctime', 'time', 'mktime']) +ALLOWED_DATETIME = set(['date', 'time', 'datetime', 'timedelta', 'tzinfo']) +ALLOWED_DT_UTIL = set([ + 'utcnow', 'now', 'as_utc', 'as_timestamp', 'as_local', + 'utc_from_timestamp', 'start_of_local_day', 'parse_datetime', 'parse_date', + 'get_age']) class ScriptError(HomeAssistantError): @@ -32,23 +51,45 @@ class ScriptError(HomeAssistantError): def setup(hass, config): - """Initialize the python_script component.""" + """Initialize the Python script component.""" path = hass.config.path(FOLDER) if not os.path.isdir(path): - _LOGGER.warning('Folder %s not found in config folder', FOLDER) + _LOGGER.warning("Folder %s not found in configuration folder", FOLDER) + return False + + discover_scripts(hass) + + def reload_scripts_handler(call): + """Handle reload service calls.""" + discover_scripts(hass) + hass.services.register(DOMAIN, SERVICE_RELOAD, reload_scripts_handler) + + return True + + +def discover_scripts(hass): + """Discover python scripts in folder.""" + path = hass.config.path(FOLDER) + + if not os.path.isdir(path): + _LOGGER.warning("Folder %s not found in configuration folder", FOLDER) return False def python_script_service_handler(call): """Handle python script service calls.""" execute_script(hass, call.service, call.data) + existing = hass.services.services.get(DOMAIN, {}).keys() + for existing_service in existing: + if existing_service == SERVICE_RELOAD: + continue + hass.services.remove(DOMAIN, existing_service) + for fil in glob.iglob(os.path.join(path, '*.py')): name = os.path.splitext(os.path.basename(fil))[0] hass.services.register(DOMAIN, name, python_script_service_handler) - return True - @bind_hass def execute_script(hass, name, data=None): @@ -63,44 +104,54 @@ def execute_script(hass, name, data=None): def execute(hass, filename, source, data=None): """Execute Python source.""" from RestrictedPython import compile_restricted_exec - from RestrictedPython.Guards import safe_builtins, full_write_guard + from RestrictedPython.Guards import safe_builtins, full_write_guard, \ + guarded_iter_unpack_sequence, guarded_unpack_sequence from RestrictedPython.Utilities import utility_builtins from RestrictedPython.Eval import default_guarded_getitem compiled = compile_restricted_exec(source, filename=filename) if compiled.errors: - _LOGGER.error('Error loading script %s: %s', filename, - ', '.join(compiled.errors)) + _LOGGER.error("Error loading script %s: %s", filename, + ", ".join(compiled.errors)) return if compiled.warnings: - _LOGGER.warning('Warning loading script %s: %s', filename, - ', '.join(compiled.warnings)) + _LOGGER.warning("Warning loading script %s: %s", filename, + ", ".join(compiled.warnings)) def protected_getattr(obj, name, default=None): """Restricted method to get attributes.""" # pylint: disable=too-many-boolean-expressions if name.startswith('async_'): - raise ScriptError('Not allowed to access async methods') + raise ScriptError("Not allowed to access async methods") elif (obj is hass and name not in ALLOWED_HASS or obj is hass.bus and name not in ALLOWED_EVENTBUS or obj is hass.states and name not in ALLOWED_STATEMACHINE or - obj is hass.services and name not in ALLOWED_SERVICEREGISTRY): - raise ScriptError('Not allowed to access {}.{}'.format( + obj is hass.services and name not in ALLOWED_SERVICEREGISTRY or + obj is dt_util and name not in ALLOWED_DT_UTIL or + obj is datetime and name not in ALLOWED_DATETIME or + isinstance(obj, TimeWrapper) and name not in ALLOWED_TIME): + raise ScriptError("Not allowed to access {}.{}".format( obj.__class__.__name__, name)) return getattr(obj, name, default) builtins = safe_builtins.copy() builtins.update(utility_builtins) + builtins['datetime'] = datetime + builtins['sorted'] = sorted + builtins['time'] = TimeWrapper() + builtins['dt_util'] = dt_util restricted_globals = { '__builtins__': builtins, '_print_': StubPrinter, '_getattr_': protected_getattr, '_write_': full_write_guard, '_getiter_': iter, - '_getitem_': default_guarded_getitem + '_getitem_': default_guarded_getitem, + '_iter_unpack_sequence_': guarded_iter_unpack_sequence, + '_unpack_sequence_': guarded_unpack_sequence, } logger = logging.getLogger('{}.{}'.format(__name__, filename)) local = { @@ -110,13 +161,13 @@ def protected_getattr(obj, name, default=None): } try: - _LOGGER.info('Executing %s: %s', filename, data) + _LOGGER.info("Executing %s: %s", filename, data) # pylint: disable=exec-used exec(compiled.code, restricted_globals, local) except ScriptError as err: - logger.error('Error executing script: %s', err) + logger.error("Error executing script: %s", err) except Exception as err: # pylint: disable=broad-except - logger.exception('Error executing script: %s', err) + logger.exception("Error executing script: %s", err) class StubPrinter: @@ -130,4 +181,31 @@ def _call_print(self, *objects, **kwargs): """Print text.""" # pylint: disable=no-self-use _LOGGER.warning( - "Don't use print() inside scripts. Use logger.info() instead.") + "Don't use print() inside scripts. Use logger.info() instead") + + +class TimeWrapper: + """Wrap the time module.""" + + # Class variable, only going to warn once per Home Assistant run + warned = False + + # pylint: disable=no-self-use + def sleep(self, *args, **kwargs): + """Sleep method that warns once.""" + if not TimeWrapper.warned: + TimeWrapper.warned = True + _LOGGER.warning("Using time.sleep can reduce the performance of " + "Home Assistant") + + time.sleep(*args, **kwargs) + + def __getattr__(self, attr): + """Fetch an attribute from Time module.""" + attribute = getattr(time, attr) + if callable(attribute): + def wrapper(*args, **kw): + """Wrap to return callable method if callable.""" + return attribute(*args, **kw) + return wrapper + return attribute diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index d5d6f657bc635..63e30a9491ede 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -8,14 +8,18 @@ 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_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 - - 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 + return self.device.value > 0 - 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) - - # pylint: disable=unused-argument - def turn_off(self, **kwargs): - """Turn the device off.""" - if self._qsusb.set(self._id, 0) >= 0: - self.update_value(0) - - -class QSSwitch(QSToggleEntity, SwitchDevice): - """Switch based on a Qwikswitch relay module.""" + new = kwargs.get(ATTR_BRIGHTNESS, 255) + self.hass.data[DOMAIN].devices.set_value(self.qsid, new) - pass - - -class QSLight(QSToggleEntity, Light): - """Light based on a Qwikswitch relay/dimmer module.""" - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_QWIKSWITCH + async def async_turn_off(self, **_): + """Turn the device off.""" + self.hass.data[DOMAIN].devices.set_value(self.qsid, 0) -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) +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 - # 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("Botton 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)) - - def _start(event): + hass.async_add_job(qsusb.update_from_devices) + + @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 new file mode 100644 index 0000000000000..76dda6fd366ff --- /dev/null +++ b/homeassistant/components/rainbird.py @@ -0,0 +1,47 @@ +""" +Support for Rain Bird Irrigation system LNK WiFi Module. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/rainbird/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_HOST, CONF_PASSWORD) + +REQUIREMENTS = ['pyrainbird==0.1.3'] + +_LOGGER = logging.getLogger(__name__) + +DATA_RAINBIRD = 'rainbird' +DOMAIN = 'rainbird' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Rain Bird component.""" + conf = config[DOMAIN] + server = conf.get(CONF_HOST) + password = conf.get(CONF_PASSWORD) + + from pyrainbird import RainbirdController + controller = RainbirdController() + controller.setConfig(server, password) + + _LOGGER.debug("Rain Bird Controller set to: %s", server) + + initial_status = controller.currentIrrigation() + if initial_status == -1: + _LOGGER.error("Error getting state. Possible configuration issues") + return False + + hass.data[DATA_RAINBIRD] = controller + return True diff --git a/homeassistant/components/raincloud.py b/homeassistant/components/raincloud.py new file mode 100644 index 0000000000000..505c3a7b2b00b --- /dev/null +++ b/homeassistant/components/raincloud.py @@ -0,0 +1,178 @@ +""" +Support for Melnor RainCloud sprinkler water timer. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/raincloud/ +""" +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_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME) +import homeassistant.helpers.config_validation as cv +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 = ['raincloudy==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] + +CONF_ATTRIBUTION = "Data provided by Melnor Aquatimer.com" +CONF_WATERING_TIME = 'watering_minutes' + +NOTIFICATION_ID = 'raincloud_notification' +NOTIFICATION_TITLE = 'Rain Cloud Setup' + +DATA_RAINCLOUD = 'raincloud' +DOMAIN = 'raincloud' +DEFAULT_WATERING_TIME = 15 + +KEY_MAP = { + 'auto_watering': 'Automatic Watering', + 'battery': 'Battery', + 'is_watering': 'Watering', + 'manual_watering': 'Manual Watering', + 'next_cycle': 'Next Cycle', + 'rain_delay': 'Rain Delay', + 'status': 'Status', + 'watering_time': 'Remaining Watering Time', +} + +ICON_MAP = { + 'auto_watering': 'mdi:autorenew', + 'battery': '', + 'is_watering': '', + 'manual_watering': 'mdi:water-pump', + 'next_cycle': 'mdi:calendar-clock', + 'rain_delay': 'mdi:weather-rainy', + 'status': '', + 'watering_time': 'mdi:water-pump', +} + +UNIT_OF_MEASUREMENT_MAP = { + 'auto_watering': '', + 'battery': '%', + 'is_watering': '', + 'manual_watering': '', + 'next_cycle': '', + 'rain_delay': 'days', + 'status': '', + 'watering_time': 'min', +} + +BINARY_SENSORS = ['is_watering', 'status'] + +SENSORS = ['battery', 'next_cycle', 'rain_delay', 'watering_time'] + +SWITCHES = ['auto_watering', 'manual_watering'] + +SCAN_INTERVAL = timedelta(seconds=20) + +SIGNAL_UPDATE_RAINCLOUD = "raincloud_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=SCAN_INTERVAL): + cv.time_period, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Melnor RainCloud component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + scan_interval = conf.get(CONF_SCAN_INTERVAL) + + try: + from raincloudy.core import RainCloudy + + raincloud = RainCloudy(username=username, password=password) + if not raincloud.is_connected: + raise HTTPError + hass.data[DATA_RAINCLOUD] = RainCloudHub(raincloud) + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Rain 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 Raincloud hub to refresh information.""" + _LOGGER.debug("Updating RainCloud Hub component") + hass.data[DATA_RAINCLOUD].data.update() + dispatcher_send(hass, SIGNAL_UPDATE_RAINCLOUD) + + # Call the Raincloud API to refresh updates + track_time_interval(hass, hub_refresh, scan_interval) + + return True + + +class RainCloudHub(object): + """Representation of a base RainCloud device.""" + + def __init__(self, data): + """Initialize the entity.""" + self.data = data + + +class RainCloudEntity(Entity): + """Entity class for RainCloud devices.""" + + def __init__(self, data, sensor_type): + """Initialize the RainCloud entity.""" + self.data = data + self._sensor_type = sensor_type + self._name = "{0} {1}".format( + self.data.name, KEY_MAP.get(self._sensor_type)) + 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_RAINCLOUD, self._update_callback) + + def _update_callback(self): + """Call update method.""" + self.schedule_update_ha_state(True) + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return UNIT_OF_MEASUREMENT_MAP.get(self._sensor_type) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'current_time': self.data.current_time, + 'identifier': self.data.serial, + } + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON_MAP.get(self._sensor_type) diff --git a/homeassistant/components/rainmachine.py b/homeassistant/components/rainmachine.py new file mode 100644 index 0000000000000..f2d5893d60bb9 --- /dev/null +++ b/homeassistant/components/rainmachine.py @@ -0,0 +1,132 @@ +""" +This component provides support for RainMachine sprinkler controllers. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/rainmachine/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_SWITCHES) +from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['regenmaschine==0.4.1'] + +_LOGGER = logging.getLogger(__name__) + +DATA_RAINMACHINE = 'data_rainmachine' +DOMAIN = 'rainmachine' + +NOTIFICATION_ID = 'rainmachine_notification' +NOTIFICATION_TITLE = 'RainMachine Component Setup' + +CONF_ZONE_RUN_TIME = 'zone_run_time' + +DEFAULT_ATTRIBUTION = 'Data provided by Green Electronics LLC' +DEFAULT_ICON = 'mdi:water' +DEFAULT_PORT = 8080 +DEFAULT_SSL = True + +PROGRAM_UPDATE_TOPIC = '{0}_program_update'.format(DOMAIN) + +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_SWITCHES): SWITCH_SCHEMA, + }) + }, + extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the RainMachine component.""" + from regenmaschine import Authenticator, Client + from regenmaschine.exceptions import HTTPError + from requests.exceptions import ConnectTimeout + + conf = config[DOMAIN] + ip_address = conf[CONF_IP_ADDRESS] + password = conf[CONF_PASSWORD] + port = conf[CONF_PORT] + ssl = conf[CONF_SSL] + + _LOGGER.debug('Setting up RainMachine client') + + try: + auth = Authenticator.create_local( + ip_address, password, port=port, https=ssl) + client = Client(auth) + hass.data[DATA_RAINMACHINE] = RainMachine(client) + except (HTTPError, ConnectTimeout, UnboundLocalError) as exc_info: + _LOGGER.error('An error occurred: %s', str(exc_info)) + hass.components.persistent_notification.create( + 'Error: {0}
    ' + 'You will need to restart hass after fixing.' + ''.format(exc_info), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + _LOGGER.debug('Setting up switch platform') + switch_config = conf.get(CONF_SWITCHES, {}) + discovery.load_platform(hass, 'switch', DOMAIN, switch_config, config) + + _LOGGER.debug('Setup complete') + + return True + + +class RainMachine(object): + """Define a generic RainMachine object.""" + + def __init__(self, client): + """Initialize.""" + self.client = client + self.device_mac = self.client.provision.wifi()['macAddress'] + + +class RainMachineEntity(Entity): + """Define a generic RainMachine entity.""" + + def __init__(self, + rainmachine, + rainmachine_type, + rainmachine_entity_id, + icon=DEFAULT_ICON): + """Initialize.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._icon = icon + self._rainmachine_type = rainmachine_type + self._rainmachine_entity_id = rainmachine_entity_id + self.rainmachine = rainmachine + + @property + def device_state_attributes(self) -> dict: + """Return the state attributes.""" + return self._attrs + + @property + def icon(self) -> str: + """Return the icon.""" + return self._icon + + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}_{2}'.format( + self.rainmachine.device_mac.replace( + ':', ''), self._rainmachine_type, + self._rainmachine_entity_id) diff --git a/homeassistant/components/raspihats.py b/homeassistant/components/raspihats.py index 3ab433f4b9103..3bc45eab34ece 100644 --- a/homeassistant/components/raspihats.py +++ b/homeassistant/components/raspihats.py @@ -4,15 +4,15 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/raspihats/ """ +# pylint: disable=import-error,no-name-in-module import logging import threading import time from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -) + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) -REQUIREMENTS = ['raspihats==2.2.1'] +REQUIREMENTS = ['raspihats==2.2.3', 'smbus-cffi==0.5.1'] _LOGGER = logging.getLogger(__name__) @@ -36,7 +36,7 @@ # pylint: disable=unused-argument def setup(hass, config): - """Setup the raspihats component.""" + """Set up the raspihats component.""" hass.data[I2C_HATS_MANAGER] = I2CHatsManager() def start_i2c_hats_keep_alive(event): @@ -72,13 +72,13 @@ class I2CHatsDIScanner(object): _CALLBACKS = "callbacks" def setup(self, i2c_hat): - """Setup I2C-HAT instance for digital inputs scanner.""" + """Set up the I2C-HAT instance for digital inputs scanner.""" if hasattr(i2c_hat, self._DIGITAL_INPUTS): digital_inputs = getattr(i2c_hat, self._DIGITAL_INPUTS) old_value = None - # add old value attribute + # Add old value attribute setattr(digital_inputs, self._OLD_VALUE, old_value) - # add callbacks dict attribute {channel: callback} + # Add callbacks dict attribute {channel: callback} setattr(digital_inputs, self._CALLBACKS, {}) def register_callback(self, i2c_hat, channel, callback): @@ -140,17 +140,14 @@ def register_board(self, board, address): self._i2c_hats[address] = i2c_hat status_word = i2c_hat.status # read status_word to reset bits _LOGGER.info( - log_message(self, i2c_hat, "registered", status_word) - ) + log_message(self, i2c_hat, "registered", status_word)) def run(self): """Keep alive for I2C-HATs.""" - # pylint: disable=import-error from raspihats.i2c_hats import ResponseException - _LOGGER.info( - log_message(self, "starting") - ) + _LOGGER.info(log_message(self, "starting")) + while self._run: with self._lock: for i2c_hat in list(self._i2c_hats.values()): @@ -175,17 +172,13 @@ def run(self): ) setattr(i2c_hat, self._EXCEPTION, ex) time.sleep(0.05) - _LOGGER.info( - log_message(self, "exiting") - ) + _LOGGER.info(log_message(self, "exiting")) def _read_status(self, i2c_hat): """Read I2C-HATs status.""" status_word = i2c_hat.status if status_word.value != 0x00: - _LOGGER.error( - log_message(self, i2c_hat, status_word) - ) + _LOGGER.error(log_message(self, i2c_hat, status_word)) def start_keep_alive(self): """Start keep alive mechanism.""" @@ -213,7 +206,6 @@ def register_online_callback(self, address, channel, callback): def read_di(self, address, channel): """Read a value from a I2C-HAT digital input.""" - # pylint: disable=import-error from raspihats.i2c_hats import ResponseException with self._lock: @@ -226,7 +218,6 @@ def read_di(self, address, channel): def write_dq(self, address, channel, value): """Write a value to a I2C-HAT digital output.""" - # pylint: disable=import-error from raspihats.i2c_hats import ResponseException with self._lock: @@ -238,7 +229,6 @@ def write_dq(self, address, channel, value): def read_dq(self, address, channel): """Read a value from a I2C-HAT digital output.""" - # pylint: disable=import-error from raspihats.i2c_hats import ResponseException with self._lock: diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 6aac0b7fafde2..9b5bea043f4ec 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -8,80 +8,89 @@ https://home-assistant.io/components/recorder/ """ import asyncio +from collections import namedtuple import concurrent.futures +from datetime import datetime, timedelta import logging import queue import threading import time -from datetime import timedelta, datetime -from typing import Optional, Dict + +from typing import Any, Dict, Optional # noqa: F401 import voluptuous as vol -from homeassistant.core import ( - HomeAssistant, callback, split_entity_id, CoreState) from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_ENTITIES, CONF_EXCLUDE, CONF_DOMAINS, - CONF_INCLUDE, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, - EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) + ATTR_ENTITY_ID, CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, + EVENT_TIME_CHANGED, MATCH_ALL) +from homeassistant.core import CoreState, HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval +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 purge, migration +from . import migration, purge from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.1.13'] +REQUIREMENTS = ['sqlalchemy==1.2.7'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'recorder' +SERVICE_PURGE = 'purge' + +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, +}) + DEFAULT_URL = 'sqlite:///{hass_config_path}' DEFAULT_DB_FILE = 'home-assistant_v2.db' CONF_DB_URL = 'db_url' -CONF_PURGE_DAYS = 'purge_days' +CONF_PURGE_KEEP_DAYS = 'purge_keep_days' +CONF_PURGE_INTERVAL = 'purge_interval' CONF_EVENT_TYPES = 'event_types' CONNECT_RETRY_WAIT = 3 FILTER_SCHEMA = vol.Schema({ vol.Optional(CONF_EXCLUDE, default={}): 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_EVENT_TYPES, default=[]): - vol.All(cv.ensure_list, [cv.string]) + vol.Optional(CONF_DOMAINS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ENTITIES): cv.entity_ids, + vol.Optional(CONF_EVENT_TYPES): vol.All(cv.ensure_list, [cv.string]), }), vol.Optional(CONF_INCLUDE, default={}): 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): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ENTITIES): cv.entity_ids, }) }) CONFIG_SCHEMA = vol.Schema({ DOMAIN: FILTER_SCHEMA.extend({ - vol.Optional(CONF_PURGE_DAYS): + vol.Optional(CONF_PURGE_KEEP_DAYS, default=10): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_PURGE_INTERVAL, default=1): + vol.All(vol.Coerce(int), vol.Range(min=0)), vol.Optional(CONF_DB_URL): cv.string, }) }, extra=vol.ALLOW_EXTRA) -def wait_connection_ready(hass): - """ - Wait till the connection is ready. - - Returns a coroutine object. - """ - return 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): +def run_information(hass, point_in_time: Optional[datetime] = None): """Return information about current run. There is also the run that covers point_in_time. @@ -102,11 +111,11 @@ 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, {}) - purge_days = conf.get(CONF_PURGE_DAYS) + keep_days = conf.get(CONF_PURGE_KEEP_DAYS) + purge_interval = conf.get(CONF_PURGE_INTERVAL) db_url = conf.get(CONF_DB_URL, None) if not db_url: @@ -116,24 +125,37 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: include = conf.get(CONF_INCLUDE, {}) exclude = conf.get(CONF_EXCLUDE, {}) instance = hass.data[DATA_INSTANCE] = Recorder( - hass, purge_days=purge_days, uri=db_url, include=include, - exclude=exclude) + hass=hass, keep_days=keep_days, purge_interval=purge_interval, + uri=db_url, include=include, exclude=exclude) instance.async_initialize() instance.start() - return (yield from instance.async_db_ready) + async def async_handle_purge_service(service): + """Handle calls to the purge service.""" + instance.do_adhoc_purge(**service.data) + + hass.services.async_register( + DOMAIN, SERVICE_PURGE, async_handle_purge_service, + schema=SERVICE_PURGE_SCHEMA) + + return await instance.async_db_ready + + +PurgeTask = namedtuple('PurgeTask', ['keep_days', 'repack']) class Recorder(threading.Thread): """A threaded recorder class.""" - def __init__(self, hass: HomeAssistant, purge_days: int, uri: str, + def __init__(self, hass: HomeAssistant, keep_days: int, + purge_interval: int, uri: str, include: Dict, exclude: Dict) -> None: """Initialize the recorder.""" threading.Thread.__init__(self, name='Recorder') self.hass = hass - self.purge_days = purge_days + self.keep_days = keep_days + self.purge_interval = purge_interval self.queue = queue.Queue() # type: Any self.recording_start = dt_util.utcnow() self.db_url = uri @@ -141,10 +163,9 @@ def __init__(self, hass: HomeAssistant, purge_days: int, uri: str, self.engine = None # type: Any self.run_info = None # type: Any - self.include_e = include.get(CONF_ENTITIES, []) - self.include_d = include.get(CONF_DOMAINS, []) - self.exclude = exclude.get(CONF_ENTITIES, []) + \ - exclude.get(CONF_DOMAINS, []) + self.entity_filter = generate_filter( + include.get(CONF_DOMAINS, []), include.get(CONF_ENTITIES, []), + exclude.get(CONF_DOMAINS, []), exclude.get(CONF_ENTITIES, [])) self.exclude_t = exclude.get(CONF_EVENT_TYPES, []) self.get_session = None @@ -154,6 +175,13 @@ def async_initialize(self): """Initialize the recorder.""" self.hass.bus.async_listen(MATCH_ALL, self.event_listener) + def do_adhoc_purge(self, **kwargs): + """Trigger an adhoc purge retaining keep_days worth of data.""" + keep_days = kwargs.get(ATTR_KEEP_DAYS, self.keep_days) + repack = kwargs.get(ATTR_REPACK) + + self.queue.put(PurgeTask(keep_days, repack)) + def run(self): """Start processing events to save.""" from .models import States, Events @@ -190,7 +218,6 @@ def connection_failed(): self.hass.add_job(connection_failed) return - purge_task = object() shutdown_task = object() hass_started = concurrent.futures.Future() @@ -206,8 +233,7 @@ def shutdown(event): self.queue.put(None) self.join() - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, - shutdown) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) if self.hass.state == CoreState.running: hass_started.set_result(None) @@ -217,25 +243,39 @@ def notify_hass_started(event): """Notify that hass has started.""" hass_started.set_result(None) - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, - notify_hass_started) - - if self.purge_days is not None: - @callback - def do_purge(now): - """Event listener for purging data.""" - self.queue.put(purge_task) - - async_track_time_interval(self.hass, do_purge, - timedelta(days=2)) + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, notify_hass_started) 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 + # Start periodic purge + if self.keep_days and self.purge_interval: + @callback + def async_purge(now): + """Trigger the purge and schedule the next run.""" + self.queue.put( + PurgeTask(self.keep_days, repack=False)) + self.hass.helpers.event.async_track_point_in_time( + async_purge, now + timedelta(days=self.purge_interval)) + + earliest = dt_util.utcnow() + timedelta(minutes=30) + run = latest = dt_util.utcnow() + \ + timedelta(days=self.purge_interval) + with session_scope(session=self.get_session()) as session: + event = session.query(Events).first() + if event is not None: + session.expunge(event) + run = dt_util.as_utc(event.time_fired) + timedelta( + days=self.keep_days+self.purge_interval) + run = min(latest, max(run, earliest)) + + self.hass.helpers.event.track_point_in_time(async_purge, run) + while True: event = self.queue.get() @@ -244,8 +284,9 @@ def do_purge(now): self._close_connection() self.queue.task_done() return - elif event is purge_task: - purge.purge_old_data(self, self.purge_days) + elif isinstance(event, PurgeTask): + purge.purge_old_data(self, event.keep_days, event.repack) + self.queue.task_done() continue elif event.event_type == EVENT_TIME_CHANGED: self.queue.task_done() @@ -256,21 +297,7 @@ def do_purge(now): entity_id = event.data.get(ATTR_ENTITY_ID) if entity_id is not None: - domain = split_entity_id(entity_id)[0] - - # Exclude entities OR - # Exclude domains, but include specific entities - if (entity_id in self.exclude) or \ - (domain in self.exclude and - entity_id not in self.include_e): - self.queue.task_done() - continue - - # Included domains only (excluded entities above) OR - # Include entities only, but only if no excludes - if (self.include_d and domain not in self.include_d) or \ - (self.include_e and entity_id not in self.include_e - and not self.exclude): + if not self.entity_filter(entity_id): self.queue.task_done() continue @@ -283,6 +310,7 @@ def do_purge(now): with session_scope(session=self.get_session()) as session: dbevent = Events.from_event(event) session.add(dbevent) + session.flush() if event.event_type == EVENT_STATE_CHANGED: dbstate = States.from_event(event) @@ -317,6 +345,7 @@ def _setup_connection(self): from sqlalchemy.engine import Engine from sqlalchemy.orm import scoped_session from sqlalchemy.orm import sessionmaker + from sqlite3 import Connection from . import models @@ -326,7 +355,7 @@ def _setup_connection(self): @event.listens_for(Engine, "connect") def set_sqlite_pragma(dbapi_connection, connection_record): """Set sqlite's WAL mode.""" - if self.db_url.startswith("sqlite://"): + if isinstance(dbapi_connection, Connection): old_isolation = dbapi_connection.isolation_level dbapi_connection.isolation_level = None cursor = dbapi_connection.cursor() diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 5a68fe43fe0a3..af70c9d998c57 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -47,7 +47,7 @@ def _create_index(engine, table_name, index_name): table = Table(table_name, models.Base.metadata) _LOGGER.debug("Looking up index for table %s", table_name) - # Look up the index object by name from the table is the the models + # Look up the index object by name from the table is the models index = next(idx for idx in table.indexes if idx.name == index_name) _LOGGER.debug("Creating %s index", index_name) _LOGGER.info("Adding index `%s` to database. Note: this can take several " @@ -143,6 +143,9 @@ def _apply_update(engine, new_version, old_version): _drop_index(engine, "states", "ix_states_entity_id_created") _create_index(engine, "states", "ix_states_entity_id_last_updated") + elif new_version == 5: + # Create supporting index for States.event_id foreign key + _create_index(engine, "states", "ix_states_event_id") else: raise ValueError("No schema migration defined for version {}" .format(new_version)) @@ -151,7 +154,7 @@ def _apply_update(engine, new_version, old_version): def _inspect_schema_version(engine, session): """Determine the schema version by inspecting the db structure. - When the schema verison is not present in the db, either db was just + When the schema version is not present in the db, either db was just created with the correct schema, or this is a db created before schema versions were tracked. For now, we'll test if the changes for schema version 1 are present to make the determination. Eventually this logic diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 7c29c8045ea14..32d6291b90ca8 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -16,7 +16,7 @@ # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 4 +SCHEMA_VERSION = 5 _LOGGER = logging.getLogger(__name__) @@ -64,7 +64,7 @@ class States(Base): # type: ignore entity_id = Column(String(255)) state = Column(String(255)) attributes = Column(Text) - event_id = Column(Integer, ForeignKey('events.event_id')) + event_id = Column(Integer, ForeignKey('events.event_id'), index=True) last_changed = Column(DateTime(timezone=True), default=datetime.utcnow) last_updated = Column(DateTime(timezone=True), default=datetime.utcnow, index=True) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 26ddefedf7dbf..4af2a62151f42 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -9,23 +9,62 @@ _LOGGER = logging.getLogger(__name__) -def purge_old_data(instance, purge_days): +def purge_old_data(instance, purge_days, repack): """Purge events and states older than purge_days ago.""" from .models import States, Events + from sqlalchemy import func + purge_before = dt_util.utcnow() - timedelta(days=purge_days) + _LOGGER.debug("Purging events before %s", purge_before) with session_scope(session=instance.get_session()) as session: - deleted_rows = session.query(States) \ - .filter((States.last_updated < purge_before)) \ - .delete(synchronize_session=False) + delete_states = session.query(States) \ + .filter((States.last_updated < purge_before)) + + # For each entity, the most recent state is protected from deletion + # s.t. we can properly restore state even if the entity has not been + # updated in a long time + protected_states = session.query(func.max(States.state_id)) \ + .group_by(States.entity_id).all() + + protected_state_ids = tuple(state[0] for state in protected_states) + + if protected_state_ids: + delete_states = delete_states \ + .filter(~States.state_id.in_(protected_state_ids)) + + deleted_rows = delete_states.delete(synchronize_session=False) _LOGGER.debug("Deleted %s states", deleted_rows) - deleted_rows = session.query(Events) \ - .filter((Events.time_fired < purge_before)) \ - .delete(synchronize_session=False) + delete_events = session.query(Events) \ + .filter((Events.time_fired < purge_before)) + + # We also need to protect the events belonging to the protected states. + # Otherwise, if the SQL server has "ON DELETE CASCADE" as default, it + # will delete the protected state when deleting its associated + # event. Also, we would be producing NULLed foreign keys otherwise. + if protected_state_ids: + protected_events = session.query(States.event_id) \ + .filter(States.state_id.in_(protected_state_ids)) \ + .filter(States.event_id.isnot(None)) \ + .all() + + protected_event_ids = tuple(state[0] for state in protected_events) + + if protected_event_ids: + delete_events = delete_events \ + .filter(~Events.event_id.in_(protected_event_ids)) + + deleted_rows = delete_events.delete(synchronize_session=False) _LOGGER.debug("Deleted %s events", deleted_rows) # Execute sqlite vacuum command to free up space on disk - if instance.engine.driver == 'sqlite': - _LOGGER.info("Vacuuming SQLite to free space") - instance.engine.execute("VACUUM") + _LOGGER.debug("DB engine driver: %s", instance.engine.driver) + if repack and instance.engine.driver == 'pysqlite': + from sqlalchemy import exc + + _LOGGER.debug("Vacuuming SQLite to free space") + try: + instance.engine.execute("VACUUM") + except exc.OperationalError as err: + _LOGGER.error("Error vacuuming SQLite: %s.", err) diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml new file mode 100644 index 0000000000000..512807c9f6942 --- /dev/null +++ b/homeassistant/components/recorder/services.yaml @@ -0,0 +1,11 @@ +# Describes the format for available recorder services + +purge: + description: Start purge task - delete events and states older than x days, according to keep_days service data. + fields: + keep_days: + description: Number of history days to keep in database after purge. Value >= 0. + example: 2 + repack: + description: Attempt to save disk space by rewriting the entire database file. + example: true diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py new file mode 100644 index 0000000000000..98cd937de3cca --- /dev/null +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -0,0 +1,332 @@ +""" +Component to interact with Remember The Milk. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/remember_the_milk/ +""" +import json +import logging +import os + +import voluptuous as vol + +from homeassistant.const import ( + CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN, STATE_OK) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent + +# httplib2 is a transitive dependency from RtmAPI. If this dependency is not +# set explicitly, the library does not work. +REQUIREMENTS = ['RtmAPI==0.7.0', 'httplib2==0.10.3'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'remember_the_milk' +DEFAULT_NAME = DOMAIN +GROUP_NAME_RTM = 'remember the milk accounts' + +CONF_SHARED_SECRET = 'shared_secret' +CONF_ID_MAP = 'id_map' +CONF_LIST_ID = 'list_id' +CONF_TIMESERIES_ID = 'timeseries_id' +CONF_TASK_ID = 'task_id' + +RTM_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_SHARED_SECRET): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA]) +}, extra=vol.ALLOW_EXTRA) + +CONFIG_FILE_NAME = '.remember_the_milk.conf' +SERVICE_CREATE_TASK = 'create_task' +SERVICE_COMPLETE_TASK = 'complete_task' + +SERVICE_SCHEMA_CREATE_TASK = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ID): cv.string, +}) + +SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({ + vol.Required(CONF_ID): cv.string, +}) + + +def setup(hass, config): + """Set up the Remember the milk component.""" + component = EntityComponent( + _LOGGER, DOMAIN, hass, group_name=GROUP_NAME_RTM) + + stored_rtm_config = RememberTheMilkConfiguration(hass) + for rtm_config in config[DOMAIN]: + account_name = rtm_config[CONF_NAME] + _LOGGER.info("Adding Remember the milk account %s", account_name) + api_key = rtm_config[CONF_API_KEY] + shared_secret = rtm_config[CONF_SHARED_SECRET] + token = stored_rtm_config.get_token(account_name) + if token: + _LOGGER.debug("found token for account %s", account_name) + _create_instance( + hass, account_name, api_key, shared_secret, token, + stored_rtm_config, component) + else: + _register_new_account( + hass, account_name, api_key, shared_secret, + stored_rtm_config, component) + + _LOGGER.debug("Finished adding all Remember the milk accounts") + return True + + +def _create_instance(hass, account_name, api_key, shared_secret, + token, stored_rtm_config, component): + entity = RememberTheMilk(account_name, api_key, shared_secret, + token, stored_rtm_config) + component.add_entities([entity]) + hass.services.register( + DOMAIN, '{}_create_task'.format(account_name), entity.create_task, + schema=SERVICE_SCHEMA_CREATE_TASK) + hass.services.register( + DOMAIN, '{}_complete_task'.format(account_name), entity.complete_task, + schema=SERVICE_SCHEMA_COMPLETE_TASK) + + +def _register_new_account(hass, account_name, api_key, shared_secret, + stored_rtm_config, component): + from rtmapi import Rtm + + request_id = None + configurator = hass.components.configurator + api = Rtm(api_key, shared_secret, "write", None) + url, frob = api.authenticate_desktop() + _LOGGER.debug("Sent authentication request to server") + + def register_account_callback(_): + """Call for register the configurator.""" + api.retrieve_token(frob) + token = api.token + if api.token is None: + _LOGGER.error("Failed to register, please try again") + configurator.notify_errors( + request_id, + 'Failed to register, please try again.') + return + + stored_rtm_config.set_token(account_name, token) + _LOGGER.debug("Retrieved new token from server") + + _create_instance( + hass, account_name, api_key, shared_secret, token, + stored_rtm_config, component) + + configurator.request_done(request_id) + + request_id = configurator.async_request_config( + '{} - {}'.format(DOMAIN, account_name), + callback=register_account_callback, + description='You need to log in to Remember The Milk to' + + 'connect your account. \n\n' + + 'Step 1: Click on the link "Remember The Milk login"\n\n' + + 'Step 2: Click on "login completed"', + link_name='Remember The Milk login', + link_url=url, + submit_caption="login completed", + ) + + +class RememberTheMilkConfiguration(object): + """Internal configuration data for RememberTheMilk class. + + This class stores the authentication token it get from the backend. + """ + + def __init__(self, hass): + """Create new instance of configuration.""" + self._config_file_path = hass.config.path(CONFIG_FILE_NAME) + if not os.path.isfile(self._config_file_path): + self._config = dict() + return + try: + _LOGGER.debug("Loading configuration from file: %s", + self._config_file_path) + with open(self._config_file_path, 'r') as config_file: + self._config = json.load(config_file) + except ValueError: + _LOGGER.error("Failed to load configuration file, creating a " + "new one: %s", self._config_file_path) + self._config = dict() + + def save_config(self): + """Write the configuration to a file.""" + with open(self._config_file_path, 'w') as config_file: + json.dump(self._config, config_file) + + def get_token(self, profile_name): + """Get the server token for a profile.""" + if profile_name in self._config: + return self._config[profile_name][CONF_TOKEN] + return None + + def set_token(self, profile_name, token): + """Store a new server token for a profile.""" + self._initialize_profile(profile_name) + self._config[profile_name][CONF_TOKEN] = token + self.save_config() + + def delete_token(self, profile_name): + """Delete a token for a profile. + + Usually called when the token has expired. + """ + self._config.pop(profile_name, None) + self.save_config() + + def _initialize_profile(self, profile_name): + """Initialize the data structures for a profile.""" + if profile_name not in self._config: + self._config[profile_name] = dict() + if CONF_ID_MAP not in self._config[profile_name]: + self._config[profile_name][CONF_ID_MAP] = dict() + + def get_rtm_id(self, profile_name, hass_id): + """Get the RTM ids for a Home Assistant task ID. + + The id of a RTM tasks consists of the tuple: + list id, timeseries id and the task id. + """ + self._initialize_profile(profile_name) + ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) + if ids is None: + return None + return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] + + def set_rtm_id(self, profile_name, hass_id, list_id, time_series_id, + rtm_task_id): + """Add/Update the RTM task ID for a Home Assistant task IS.""" + self._initialize_profile(profile_name) + id_tuple = { + CONF_LIST_ID: list_id, + CONF_TIMESERIES_ID: time_series_id, + CONF_TASK_ID: rtm_task_id, + } + self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple + self.save_config() + + def delete_rtm_id(self, profile_name, hass_id): + """Delete a key mapping.""" + self._initialize_profile(profile_name) + if hass_id in self._config[profile_name][CONF_ID_MAP]: + del self._config[profile_name][CONF_ID_MAP][hass_id] + self.save_config() + + +class RememberTheMilk(Entity): + """Representation of an interface to Remember The Milk.""" + + def __init__(self, name, api_key, shared_secret, token, rtm_config): + """Create new instance of Remember The Milk component.""" + import rtmapi + + self._name = name + self._api_key = api_key + self._shared_secret = shared_secret + self._token = token + self._rtm_config = rtm_config + self._rtm_api = rtmapi.Rtm(api_key, shared_secret, "delete", token) + self._token_valid = None + self._check_token() + _LOGGER.debug("Instance created for account %s", self._name) + + def _check_token(self): + """Check if the API token is still valid. + + If it is not valid any more, delete it from the configuration. This + will trigger a new authentication process. + """ + valid = self._rtm_api.token_valid() + if not valid: + _LOGGER.error("Token for account %s is invalid. You need to " + "register again!", self.name) + self._rtm_config.delete_token(self._name) + self._token_valid = False + else: + self._token_valid = True + return self._token_valid + + def create_task(self, call): + """Create a new task on Remember The Milk. + + You can use the smart syntax to define the attributes of a new task, + e.g. "my task #some_tag ^today" will add tag "some_tag" and set the + due date to today. + """ + import rtmapi + + try: + task_name = call.data.get(CONF_NAME) + hass_id = call.data.get(CONF_ID) + rtm_id = None + if hass_id is not None: + rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) + result = self._rtm_api.rtm.timelines.create() + timeline = result.timeline.value + + if hass_id is None or rtm_id is None: + result = self._rtm_api.rtm.tasks.add( + timeline=timeline, name=task_name, parse='1') + _LOGGER.debug("Created new task '%s' in account %s", + task_name, self.name) + self._rtm_config.set_rtm_id( + self._name, hass_id, result.list.id, + result.list.taskseries.id, result.list.taskseries.task.id) + else: + self._rtm_api.rtm.tasks.setName( + name=task_name, list_id=rtm_id[0], taskseries_id=rtm_id[1], + task_id=rtm_id[2], timeline=timeline) + _LOGGER.debug("Updated task with id '%s' in account " + "%s to name %s", hass_id, self.name, task_name) + except rtmapi.RtmRequestFailedException as rtm_exception: + _LOGGER.error("Error creating new Remember The Milk task for " + "account %s: %s", self._name, rtm_exception) + return False + return True + + def complete_task(self, call): + """Complete a task that was previously created by this component.""" + import rtmapi + + hass_id = call.data.get(CONF_ID) + rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) + if rtm_id is None: + _LOGGER.error("Could not find task with ID %s in account %s. " + "So task could not be closed", hass_id, self._name) + return False + try: + result = self._rtm_api.rtm.timelines.create() + timeline = result.timeline.value + self._rtm_api.rtm.tasks.complete( + list_id=rtm_id[0], taskseries_id=rtm_id[1], task_id=rtm_id[2], + timeline=timeline) + self._rtm_config.delete_rtm_id(self._name, hass_id) + _LOGGER.debug("Completed task with id %s in account %s", + hass_id, self._name) + except rtmapi.RtmRequestFailedException as rtm_exception: + _LOGGER.error("Error creating new Remember The Milk task for " + "account %s: %s", self._name, rtm_exception) + return True + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if not self._token_valid: + return "API token invalid" + return STATE_OK diff --git a/homeassistant/components/remember_the_milk/services.yaml b/homeassistant/components/remember_the_milk/services.yaml new file mode 100644 index 0000000000000..74a2c3a4d4fc0 --- /dev/null +++ b/homeassistant/components/remember_the_milk/services.yaml @@ -0,0 +1,24 @@ +# Describes the format for available Remember The Milk services + +create_task: + description: > + Create (or update) a new task in your Remember The Milk account. If you want to update a task + later on, you have to set an "id" when creating the task. + Note: Updating a tasks does not support the smart syntax. + + fields: + name: + description: name of the new task, you can use the smart syntax here + example: 'do this ^today #from_hass' + + id: + description: (optional) identifier for the task you're creating, can be used to update or complete the task later on + example: myid + +complete_task: + description: Complete a tasks that was privously created. + + fields: + id: + description: identifier that was defined when creating the task + example: myid \ No newline at end of file diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py old mode 100755 new mode 100644 index e975460be58cd..ddae36b92a70e --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -8,11 +8,9 @@ from datetime import timedelta import functools as ft import logging -import os import voluptuous as vol -from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import ToggleEntity @@ -61,8 +59,7 @@ vol.Optional(ATTR_DEVICE): cv.string, vol.Optional( ATTR_NUM_REPEATS, default=DEFAULT_NUM_REPEATS): cv.positive_int, - vol.Optional( - ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float) + vol.Optional(ATTR_DELAY_SECS): vol.Coerce(float), }) @@ -141,58 +138,37 @@ def async_setup(hass, config): def async_handle_remote_service(service): """Handle calls to the remote services.""" target_remotes = component.async_extract_from_service(service) + kwargs = service.data.copy() - activity_id = service.data.get(ATTR_ACTIVITY) - device = service.data.get(ATTR_DEVICE) - command = service.data.get(ATTR_COMMAND) - num_repeats = service.data.get(ATTR_NUM_REPEATS) - delay_secs = service.data.get(ATTR_DELAY_SECS) - + update_tasks = [] for remote in target_remotes: if service.service == SERVICE_TURN_ON: - yield from remote.async_turn_on(activity=activity_id) + yield from remote.async_turn_on(**kwargs) elif service.service == SERVICE_TOGGLE: - yield from remote.async_toggle(activity=activity_id) + yield from remote.async_toggle(**kwargs) elif service.service == SERVICE_SEND_COMMAND: - yield from remote.async_send_command( - device=device, command=command, - num_repeats=num_repeats, delay_secs=delay_secs) + yield from remote.async_send_command(**kwargs) else: - yield from remote.async_turn_off(activity=activity_id) + yield from remote.async_turn_off(**kwargs) - update_tasks = [] - for remote in target_remotes: if not remote.should_poll: continue - - update_coro = hass.async_add_job( - remote.async_update_ha_state(True)) - if hasattr(remote, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(remote.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml')) hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_handle_remote_service, - descriptions.get(SERVICE_TURN_OFF), schema=REMOTE_SERVICE_ACTIVITY_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_handle_remote_service, - descriptions.get(SERVICE_TURN_ON), schema=REMOTE_SERVICE_ACTIVITY_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_TOGGLE, async_handle_remote_service, - descriptions.get(SERVICE_TOGGLE), schema=REMOTE_SERVICE_ACTIVITY_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_SEND_COMMAND, async_handle_remote_service, - descriptions.get(SERVICE_SEND_COMMAND), schema=REMOTE_SERVICE_SEND_COMMAND_SCHEMA) return True diff --git a/homeassistant/components/remote/apple_tv.py b/homeassistant/components/remote/apple_tv.py index 36eee4b284ed5..7d11c931a656e 100644 --- a/homeassistant/components/remote/apple_tv.py +++ b/homeassistant/components/remote/apple_tv.py @@ -45,7 +45,7 @@ def name(self): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._atv.metadata.device_id @property diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py old mode 100755 new mode 100644 index b25741207de61..842dce087e809 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -4,23 +4,23 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/remote.harmony/ """ +import asyncio import logging -from os import path -import urllib.parse +import time import voluptuous as vol import homeassistant.components.remote as remote -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, ATTR_ENTITY_ID) from homeassistant.components.remote import ( - PLATFORM_SCHEMA, DOMAIN, ATTR_DEVICE, ATTR_ACTIVITY, ATTR_NUM_REPEATS, - ATTR_DELAY_SECS) + ATTR_ACTIVITY, ATTR_DELAY_SECS, ATTR_DEVICE, ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, DOMAIN, PLATFORM_SCHEMA) +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import PlatformNotReady from homeassistant.util import slugify -from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['pyharmony==1.0.16'] +REQUIREMENTS = ['pyharmony==1.0.20'] _LOGGER = logging.getLogger(__name__) @@ -31,10 +31,12 @@ SERVICE_SYNC = 'harmony_sync' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(ATTR_ACTIVITY): cv.string, vol.Required(CONF_NAME): cv.string, + vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): + vol.Coerce(float), vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Required(ATTR_ACTIVITY, default=None): cv.string, }) HARMONY_SYNC_SCHEMA = vol.Schema({ @@ -44,8 +46,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Harmony platform.""" - import pyharmony - host = None activity = None @@ -59,8 +59,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): False) port = DEFAULT_PORT + delay_secs = DEFAULT_DELAY_SECS if override: activity = override.get(ATTR_ACTIVITY) + delay_secs = override.get(ATTR_DELAY_SECS) port = override.get(CONF_PORT, DEFAULT_PORT) host = ( @@ -69,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): port) # Ignore hub name when checking if this hub is known - ip and port only - if host and host[1:] in (h.host for h in DEVICES): + if host[1:] in ((h.host, h.port) for h in DEVICES): _LOGGER.debug("Discovered host already known: %s", host) return elif CONF_HOST in config: @@ -79,6 +81,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(CONF_PORT), ) activity = config.get(ATTR_ACTIVITY) + delay_secs = config.get(ATTR_DELAY_SECS) else: hass.data[CONF_DEVICE_CACHE].append(config) return @@ -86,35 +89,23 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name, address, port = host _LOGGER.info("Loading Harmony Platform: %s at %s:%s, startup activity: %s", name, address, port, activity) - try: - _LOGGER.debug("Calling pyharmony.ha_get_token for remote at: %s:%s", - address, port) - token = urllib.parse.quote_plus(pyharmony.ha_get_token(address, port)) - _LOGGER.debug("Received token: %s", token) - except ValueError as err: - _LOGGER.warning("%s for remote: %s", err.args[0], name) - return False harmony_conf_file = hass.config.path( '{}{}{}'.format('harmony_', slugify(name), '.conf')) - device = HarmonyRemote( - name, address, port, - activity, harmony_conf_file, token) - - DEVICES.append(device) - - add_devices([device]) - register_services(hass) - return True + try: + device = HarmonyRemote( + name, address, port, activity, harmony_conf_file, delay_secs) + DEVICES.append(device) + add_devices([device]) + register_services(hass) + except (ValueError, AttributeError): + raise PlatformNotReady def register_services(hass): """Register all services for harmony devices.""" - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) - hass.services.register( - DOMAIN, SERVICE_SYNC, _sync_service, descriptions.get(SERVICE_SYNC), + DOMAIN, SERVICE_SYNC, _sync_service, schema=HARMONY_SYNC_SCHEMA) @@ -140,7 +131,7 @@ def _sync_service(service): class HarmonyRemote(remote.RemoteDevice): """Remote representation used to control a Harmony device.""" - def __init__(self, name, host, port, activity, out_path, token): + def __init__(self, name, host, port, activity, out_path, delay_secs): """Initialize HarmonyRemote class.""" import pyharmony from pathlib import Path @@ -148,24 +139,39 @@ def __init__(self, name, host, port, activity, out_path, token): _LOGGER.debug("HarmonyRemote device init started for: %s", name) self._name = name self.host = host - self._port = port + self.port = port self._state = None self._current_activity = None self._default_activity = activity - self._token = token + self._client = pyharmony.get_client(host, port, self.new_activity) self._config_path = out_path - _LOGGER.debug("Retrieving harmony config using token: %s", token) - self._config = pyharmony.ha_get_config(self._token, host, port) + self._config = self._client.get_config() if not Path(self._config_path).is_file(): _LOGGER.debug("Writing harmony configuration to file: %s", out_path) pyharmony.ha_write_config_file(self._config, self._config_path) + self._delay_secs = delay_secs + + @asyncio.coroutine + def async_added_to_hass(self): + """Complete the initialization.""" + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + lambda event: self._client.disconnect(wait=True)) + + # Poll for initial state + self.new_activity(self._client.get_current_activity()) @property def name(self): """Return the Harmony device's name.""" return self._name + @property + def should_poll(self): + """Return the fact that we should not be polled.""" + return False + @property def device_state_attributes(self): """Add platform specific attributes.""" @@ -176,60 +182,52 @@ def is_on(self): """Return False if PowerOff is the current activity, otherwise True.""" return self._current_activity not in [None, 'PowerOff'] - def update(self): - """Return current activity.""" + def new_activity(self, activity_id): + """Call for updating the current activity.""" import pyharmony - name = self._name - _LOGGER.debug("Polling %s for current activity", name) - state = pyharmony.ha_get_current_activity( - self._token, self._config, self.host, self._port) - _LOGGER.debug("%s current activity reported as: %s", name, state) - self._current_activity = state - self._state = bool(state != 'PowerOff') + activity_name = pyharmony.activity_name(self._config, activity_id) + _LOGGER.debug("%s activity reported as: %s", self._name, activity_name) + self._current_activity = activity_name + self._state = bool(self._current_activity != 'PowerOff') + self.schedule_update_ha_state() def turn_on(self, **kwargs): """Start an activity from the Harmony device.""" import pyharmony - if kwargs[ATTR_ACTIVITY]: - activity = kwargs[ATTR_ACTIVITY] - else: - activity = self._default_activity + activity = kwargs.get(ATTR_ACTIVITY, self._default_activity) if activity: - pyharmony.ha_start_activity( - self._token, self.host, self._port, self._config, activity) + activity_id = pyharmony.activity_id(self._config, activity) + self._client.start_activity(activity_id) self._state = True else: _LOGGER.error("No activity specified with turn_on service") def turn_off(self, **kwargs): """Start the PowerOff activity.""" - import pyharmony - pyharmony.ha_power_off(self._token, self.host, self._port) + self._client.power_off() - def send_command(self, command, **kwargs): - """Send a set of commands to one device.""" - import pyharmony - device = kwargs.pop(ATTR_DEVICE, None) + # pylint: disable=arguments-differ + def send_command(self, commands, **kwargs): + """Send a list of commands to one device.""" + device = kwargs.get(ATTR_DEVICE) if device is None: _LOGGER.error("Missing required argument: device") return - params = {} - num_repeats = kwargs.pop(ATTR_NUM_REPEATS, None) - if num_repeats is not None: - params['repeat_num'] = num_repeats - delay_secs = kwargs.pop(ATTR_DELAY_SECS, None) - if delay_secs is not None: - params['delay_secs'] = delay_secs - pyharmony.ha_send_commands( - self._token, self.host, self._port, device, command, **params) + + num_repeats = kwargs.get(ATTR_NUM_REPEATS) + delay_secs = kwargs.get(ATTR_DELAY_SECS, self._delay_secs) + + for _ in range(num_repeats): + for command in commands: + self._client.send_command(device, command) + time.sleep(delay_secs) def sync(self): """Sync the Harmony device with the web service.""" import pyharmony _LOGGER.debug("Syncing hub with Harmony servers") - pyharmony.ha_sync(self._token, self.host, self._port) - self._config = pyharmony.ha_get_config( - self._token, self.host, self._port) + self._client.sync() + self._config = self._client.get_config() _LOGGER.debug("Writing hub config to file: %s", self._config_path) pyharmony.ha_write_config_file(self._config, self._config_path) diff --git a/homeassistant/components/remote/itach.py b/homeassistant/components/remote/itach.py index eefa1ed79af12..8b91e5356b416 100644 --- a/homeassistant/components/remote/itach.py +++ b/homeassistant/components/remote/itach.py @@ -16,7 +16,7 @@ CONF_DEVICES) from homeassistant.components.remote import PLATFORM_SCHEMA -REQUIREMENTS = ['pyitachip2ir==0.0.6'] +REQUIREMENTS = ['pyitachip2ir==0.0.7'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/remote/kira.py b/homeassistant/components/remote/kira.py old mode 100755 new mode 100644 index c1a04718d33e4..42d4ce7705463 --- a/homeassistant/components/remote/kira.py +++ b/homeassistant/components/remote/kira.py @@ -4,15 +4,13 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/remote.kira/ """ -import logging import functools as ft +import logging import homeassistant.components.remote as remote +from homeassistant.const import CONF_DEVICE, CONF_NAME from homeassistant.helpers.entity import Entity -from homeassistant.const import ( - CONF_DEVICE, CONF_NAME) - DOMAIN = 'kira' _LOGGER = logging.getLogger(__name__) @@ -50,8 +48,8 @@ def update(self): def send_command(self, command, **kwargs): """Send a command to one device.""" - for singel_command in command: - code_tuple = (singel_command, + for single_command in command: + code_tuple = (single_command, kwargs.get(remote.ATTR_DEVICE)) _LOGGER.info("Sending Command: %s to %s", *code_tuple) self._kira.sendCode(code_tuple) diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml index e59cd709a7123..25ad626f96dee 100644 --- a/homeassistant/components/remote/services.yaml +++ b/homeassistant/components/remote/services.yaml @@ -1,56 +1,64 @@ # Describes the format for available remote services turn_on: - description: Sends the Power On Command - + description: Sends the Power On Command. fields: entity_id: - description: Name(s) of entities to turn on + description: Name(s) of entities to turn on. example: 'remote.family_room' activity: - description: Activity ID or Activity Name to start + description: Activity ID or Activity Name to start. example: 'BedroomTV' toggle: - description: Toggles a device - + description: Toggles a device. fields: entity_id: - description: Name(s) of entities to toggle + description: Name(s) of entities to toggle. example: 'remote.family_room' turn_off: - description: Sends the Power Off Command - + description: Sends the Power Off Command. fields: entity_id: - description: Name(s) of entities to turn off + description: Name(s) of entities to turn off. example: 'remote.family_room' send_command: - description: Sends a single command to a single device - + description: Sends a single command to a single device. fields: entity_id: - description: Name(s) of entities to send command from + description: Name(s) of entities to send command from. example: 'remote.family_room' device: - description: Device ID to send command to + description: Device ID to send command to. example: '32756745' command: description: A single command or a list of commands to send. example: 'Play' num_repeats: - description: An optional value that specifies the number of times you want to repeat the command(s). If not specified, the command(s) will not be repeated + description: An optional value that specifies the number of times you want to repeat the command(s). If not specified, the command(s) will not be repeated. example: '5' delay_secs: - description: An optional value that specifies that number of seconds you want to wait in between repeated commands. If not specified, the default of 0.4 seconds will be used + description: An optional value that specifies that number of seconds you want to wait in between repeated commands. If not specified, the default of 0.4 seconds will be used. example: '0.75' harmony_sync: - description: Syncs the remote's configuration - + description: Syncs the remote's configuration. fields: entity_id: - description: Name(s) of entities to sync + description: Name(s) of entities to sync. example: 'remote.family_room' + +xiaomi_miio_learn_command: + description: 'Learn an IR command, press "Call Service", point the remote at the IR device, and the learned command will be shown as a notification in Overview.' + fields: + entity_id: + description: 'Name of the entity to learn command from.' + example: 'remote.xiaomi_miio' + slot: + description: 'Define the slot used to save the IR command (Value from 1 to 1000000)' + example: '1' + timeout: + description: 'Define the timeout in seconds, before which the command must be learned.' + example: '30' diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py new file mode 100644 index 0000000000000..e731d421e69f8 --- /dev/null +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -0,0 +1,270 @@ +""" +Support for the Xiaomi IR Remote (Chuangmi IR). + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/remote.xiaomi_miio/ +""" +import asyncio +import logging +import time + +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.remote import ( + PLATFORM_SCHEMA, DOMAIN, ATTR_NUM_REPEATS, ATTR_DELAY_SECS, + DEFAULT_DELAY_SECS, RemoteDevice) +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.9', 'construct==2.9.41'] + +_LOGGER = logging.getLogger(__name__) + +SERVICE_LEARN = 'xiaomi_miio_learn_command' +DATA_KEY = 'remote.xiaomi_miio' + +CONF_SLOT = 'slot' +CONF_COMMANDS = 'commands' + +DEFAULT_TIMEOUT = 10 +DEFAULT_SLOT = 1 + +LEARN_COMMAND_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): vol.All(str), + vol.Optional(CONF_TIMEOUT, default=10): + vol.All(int, vol.Range(min=0)), + vol.Optional(CONF_SLOT, default=1): + vol.All(int, vol.Range(min=1, max=1000000)), +}) + +COMMAND_SCHEMA = vol.Schema({ + vol.Required(CONF_COMMAND): vol.All(cv.ensure_list, [cv.string]) + }) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): + vol.All(int, vol.Range(min=0)), + vol.Optional(CONF_SLOT, default=DEFAULT_SLOT): + vol.All(int, vol.Range(min=1, max=1000000)), + vol.Optional(ATTR_HIDDEN, default=True): cv.boolean, + vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)), + vol.Optional(CONF_COMMANDS, default={}): + vol.Schema({cv.slug: COMMAND_SCHEMA}), +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Xiaomi IR Remote (Chuangmi IR) platform.""" + from miio import ChuangmiIr, DeviceException + + host = config.get(CONF_HOST) + token = config.get(CONF_TOKEN) + + # Create handler + _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + + # The Chuang Mi IR Remote Controller wants to be re-discovered every + # 5 minutes. As long as polling is disabled the device should be + # re-discovered (lazy_discover=False) in front of every command. + device = ChuangmiIr(host, token, lazy_discover=False) + + # Check that we can communicate with device. + try: + 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("Device unavailable or token incorrect: %s", ex) + raise PlatformNotReady + + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} + + friendly_name = config.get(CONF_NAME, "xiaomi_miio_" + + host.replace('.', '_')) + slot = config.get(CONF_SLOT) + timeout = config.get(CONF_TIMEOUT) + + hidden = config.get(ATTR_HIDDEN) + + xiaomi_miio_remote = XiaomiMiioRemote(friendly_name, device, unique_id, + slot, timeout, hidden, + config.get(CONF_COMMANDS)) + + hass.data[DATA_KEY][host] = xiaomi_miio_remote + + async_add_devices([xiaomi_miio_remote]) + + @asyncio.coroutine + def async_service_handler(service): + """Handle a learn command.""" + if service.service != SERVICE_LEARN: + _LOGGER.error("We should not handle service: %s", service.service) + return + + entity_id = service.data.get(ATTR_ENTITY_ID) + entity = None + for remote in hass.data[DATA_KEY].values(): + if remote.entity_id == entity_id: + entity = remote + + if not entity: + _LOGGER.error("entity_id: '%s' not found", entity_id) + return + + device = entity.device + + slot = service.data.get(CONF_SLOT, entity.slot) + + yield from hass.async_add_job(device.learn, slot) + + timeout = service.data.get(CONF_TIMEOUT, entity.timeout) + + _LOGGER.info("Press the key you want Home Assistant to learn") + start_time = utcnow() + while (utcnow() - start_time) < timedelta(seconds=timeout): + message = yield from hass.async_add_job( + device.read, slot) + _LOGGER.debug("Message received from device: '%s'", message) + + if 'code' in message and message['code']: + log_msg = "Received command is: {}".format(message['code']) + _LOGGER.info(log_msg) + hass.components.persistent_notification.async_create( + log_msg, title='Xiaomi Miio Remote') + return + + if ('error' in message and + message['error']['message'] == "learn timeout"): + yield from hass.async_add_job(device.learn, slot) + + yield from asyncio.sleep(1, loop=hass.loop) + + _LOGGER.error("Timeout. No infrared command captured") + hass.components.persistent_notification.async_create( + "Timeout. No infrared command captured", + title='Xiaomi Miio Remote') + + hass.services.async_register(DOMAIN, SERVICE_LEARN, async_service_handler, + schema=LEARN_COMMAND_SCHEMA) + + +class XiaomiMiioRemote(RemoteDevice): + """Representation of a Xiaomi Miio Remote 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.""" + return self._name + + @property + def device(self): + """Return the remote object.""" + return self._device + + @property + def hidden(self): + """Return if we should hide entity.""" + return self._is_hidden + + @property + def slot(self): + """Return the slot to save learned command.""" + return self._slot + + @property + def timeout(self): + """Return the timeout for learning command.""" + return self._timeout + + @property + def is_on(self): + """Return False if device is unreachable, else True.""" + from miio import DeviceException + try: + self.device.info() + return True + except DeviceException: + return False + + @property + def should_poll(self): + """We should not be polled for device up state.""" + return False + + @property + def device_state_attributes(self): + """Hide remote by default.""" + if self._is_hidden: + return {'hidden': 'true'} + return + + # pylint: disable=R0201 + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the device on.""" + _LOGGER.error("Device does not support turn_on, " + + "please use 'remote.send_command' to send commands.") + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the device off.""" + _LOGGER.error("Device does not support turn_off, " + + "please use 'remote.send_command' to send commands.") + + def _send_command(self, payload): + """Send a command.""" + from miio import DeviceException + + _LOGGER.debug("Sending payload: '%s'", payload) + try: + self.device.play(payload) + except DeviceException as ex: + _LOGGER.error( + "Transmit of IR command failed, %s, exception: %s", + payload, ex) + + def send_command(self, command, **kwargs): + """Wrapper for _send_command.""" + num_repeats = kwargs.get(ATTR_NUM_REPEATS) + + delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + + for _ in range(num_repeats): + for payload in command: + if payload in self._commands: + for local_payload in self._commands[payload][CONF_COMMAND]: + self._send_command(local_payload) + else: + self._send_command(payload) + time.sleep(delay) diff --git a/homeassistant/components/rest_command.py b/homeassistant/components/rest_command.py index 026f0e9a19b37..4632315b757f8 100644 --- a/homeassistant/components/rest_command.py +++ b/homeassistant/components/rest_command.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_TIMEOUT, CONF_USERNAME, CONF_PASSWORD, CONF_URL, CONF_PAYLOAD, - CONF_METHOD) + CONF_METHOD, CONF_HEADERS) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -38,6 +38,7 @@ vol.Required(CONF_URL): cv.template, vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.All(vol.Lower, vol.In(SUPPORT_REST_METHODS)), + vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, vol.Optional(CONF_PAYLOAD): cv.template, @@ -77,9 +78,14 @@ def async_register_rest_command(name, command_config): template_payload.hass = hass headers = None + if CONF_HEADERS in command_config: + headers = command_config[CONF_HEADERS] + if CONF_CONTENT_TYPE in command_config: content_type = command_config[CONF_CONTENT_TYPE] - headers = {hdrs.CONTENT_TYPE: content_type} + if headers is None: + headers = {} + headers[hdrs.CONTENT_TYPE] = content_type @asyncio.coroutine def async_service_handler(service): diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index fe3e954c571fc..87e2a7a2331eb 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -8,10 +8,10 @@ from collections import defaultdict import functools as ft import logging -import os - import async_timeout -from homeassistant.config import load_yaml_config_file + +import voluptuous as vol + from homeassistant.const import ( ATTR_ENTITY_ID, CONF_COMMAND, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) @@ -20,9 +20,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.deprecation import get_deprecated from homeassistant.helpers.entity import Entity -import voluptuous as vol -REQUIREMENTS = ['rflink==0.0.34'] + +REQUIREMENTS = ['rflink==0.0.37'] _LOGGER = logging.getLogger(__name__) @@ -74,7 +74,7 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_PORT): vol.Any(cv.port, cv.string), - vol.Optional(CONF_HOST, default=None): cv.string, + vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_WAIT_FOR_ACK, default=True): cv.boolean, vol.Optional(CONF_RECONNECT_INTERVAL, default=DEFAULT_RECONNECT_INTERVAL): int, @@ -130,14 +130,9 @@ def async_send_command(call): call.data.get(CONF_COMMAND))): _LOGGER.error('Failed Rflink command for %s', str(call.data)) - descriptions = yield from hass.async_add_job( - load_yaml_config_file, os.path.join( - os.path.dirname(__file__), 'services.yaml') - ) - hass.services.async_register( DOMAIN, SERVICE_SEND_COMMAND, async_send_command, - descriptions[DOMAIN][SERVICE_SEND_COMMAND], SEND_COMMAND_SCHEMA) + schema=SEND_COMMAND_SCHEMA) @callback def event_callback(event): @@ -180,7 +175,7 @@ def event_callback(event): hass.data[DATA_DEVICE_REGISTER][event_type], event) # When connecting to tcp host instead of serial port (optional) - host = config[DOMAIN][CONF_HOST] + host = config[DOMAIN].get(CONF_HOST) # TCP port when host configured, otherwise serial port port = config[DOMAIN][CONF_PORT] @@ -272,7 +267,7 @@ def handle_event(self, event): self._handle_event(event) # Propagate changes through ha - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() # Put command onto bus for user to subscribe to if self._should_fire_event and identify_event_type( @@ -370,6 +365,19 @@ def _async_handle_command(self, command, *args): # if the state is true, it gets set as false self._state = self._state in [STATE_UNKNOWN, False] + # Cover options for RFlink + elif command == 'close_cover': + cmd = 'DOWN' + self._state = False + + elif command == 'open_cover': + cmd = 'UP' + self._state = True + + elif command == 'stop_cover': + cmd = 'STOP' + self._state = True + # Send initial command and queue repetitions. # This allows the entity state to be updated quickly and not having to # wait for all repetitions to be sent @@ -382,7 +390,7 @@ def cancel_queued_send_commands(self): """Cancel queued signal repetition commands. For example when user changed state while repetitions are still - queued for broadcast. Or when a incoming Rflink command (remote + queued for broadcast. Or when an incoming Rflink command (remote switch) changes the state. """ # cancel any outstanding tasks from the previous state change diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index 0c5acd3f7fa24..2f170a206461f 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -4,22 +4,20 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/rfxtrx/ """ - -import logging +import asyncio 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_DEVICE_CLASS, CONF_COMMAND_ON, CONF_COMMAND_OFF -) + 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.20.1'] +REQUIREMENTS = ['pyRFXtrx==0.22.1'] DOMAIN = 'rfxtrx' @@ -28,15 +26,17 @@ ATTR_AUTOMATIC_ADD = 'automatic_add' ATTR_DEVICE = 'device' ATTR_DEBUG = 'debug' -ATTR_STATE = 'state' -ATTR_NAME = 'name' -ATTR_FIREEVENT = 'fire_event' +ATTR_FIRE_EVENT = 'fire_event' ATTR_DATA_TYPE = 'data_type' -ATTR_DATA_BITS = 'data_bits' ATTR_DUMMY = 'dummy' -ATTR_OFF_DELAY = 'off_delay' +CONF_DATA_BITS = 'data_bits' +CONF_AUTOMATIC_ADD = 'automatic_add' +CONF_DATA_TYPE = 'data_type' CONF_SIGNAL_REPETITIONS = 'signal_repetitions' -CONF_DEVICES = 'devices' +CONF_FIRE_EVENT = 'fire_event' +CONF_DUMMY = 'dummy' +CONF_DEBUG = 'debug' +CONF_OFF_DELAY = 'off_delay' EVENT_BUTTON_PRESSED = 'button_pressed' DATA_TYPES = OrderedDict([ @@ -56,93 +56,13 @@ RECEIVED_EVT_SUBSCRIBERS = [] RFX_DEVICES = {} _LOGGER = logging.getLogger(__name__) -RFXOBJECT = 'rfxobject' - - -def _valid_device(value, device_type): - """Validate a dictionary of devices definitions.""" - config = OrderedDict() - for key, device in value.items(): - - # Still accept old configuration - if 'packetid' in device.keys(): - msg = 'You are using an outdated configuration of the rfxtrx ' +\ - 'device, {}.'.format(key) +\ - ' Your new config should be:\n {}: \n name: {}'\ - .format(device.get('packetid'), - device.get(ATTR_NAME, 'deivce_name')) - _LOGGER.warning(msg) - key = device.get('packetid') - device.pop('packetid') - - key = str(key) - if not len(key) % 2 == 0: - key = '0' + key - - if device_type == 'sensor': - config[key] = DEVICE_SCHEMA_SENSOR(device) - elif device_type == 'binary_sensor': - config[key] = DEVICE_SCHEMA_BINARYSENSOR(device) - elif device_type == 'light_switch': - config[key] = DEVICE_SCHEMA(device) - else: - raise vol.Invalid('Rfxtrx device is invalid') - - if not config[key][ATTR_NAME]: - config[key][ATTR_NAME] = key - return config - - -def valid_sensor(value): - """Validate sensor configuration.""" - return _valid_device(value, "sensor") - - -def valid_binary_sensor(value): - """Validate binary sensor configuration.""" - return _valid_device(value, "binary_sensor") - - -def _valid_light_switch(value): - return _valid_device(value, "light_switch") - - -DEVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_NAME): cv.string, - vol.Optional(ATTR_FIREEVENT, default=False): cv.boolean, -}) - -DEVICE_SCHEMA_SENSOR = vol.Schema({ - vol.Optional(ATTR_NAME, default=None): cv.string, - vol.Optional(ATTR_FIREEVENT, default=False): cv.boolean, - vol.Optional(ATTR_DATA_TYPE, default=[]): - vol.All(cv.ensure_list, [vol.In(DATA_TYPES.keys())]), -}) - -DEVICE_SCHEMA_BINARYSENSOR = vol.Schema({ - vol.Optional(ATTR_NAME, default=None): cv.string, - vol.Optional(CONF_DEVICE_CLASS, default=None): cv.string, - vol.Optional(ATTR_FIREEVENT, default=False): cv.boolean, - vol.Optional(ATTR_OFF_DELAY, default=None): - vol.Any(cv.time_period, cv.positive_timedelta), - vol.Optional(ATTR_DATA_BITS, default=None): cv.positive_int, - vol.Optional(CONF_COMMAND_ON, default=None): cv.byte, - vol.Optional(CONF_COMMAND_OFF, default=None): cv.byte -}) - -DEFAULT_SCHEMA = vol.Schema({ - vol.Required("platform"): DOMAIN, - vol.Optional(CONF_DEVICES, default={}): vol.All(dict, _valid_light_switch), - vol.Optional(ATTR_AUTOMATIC_ADD, default=False): cv.boolean, - vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS): - vol.Coerce(int), -}) +DATA_RFXOBJECT = 'rfxobject' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(ATTR_DEVICE): cv.string, - vol.Optional(ATTR_DEBUG, default=False): cv.boolean, - vol.Optional(ATTR_DUMMY, default=False): cv.boolean, + vol.Required(CONF_DEVICE): cv.string, + vol.Optional(CONF_DEBUG, default=False): cv.boolean, + vol.Optional(CONF_DUMMY, default=False): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) @@ -151,7 +71,7 @@ def setup(hass, config): """Set up the RFXtrx component.""" # Declare the Handle event def handle_receive(event): - """Handle revieved messgaes from RFXtrx gateway.""" + """Handle received messages from RFXtrx gateway.""" # Log RFXCOM event if not event.device.id_string: return @@ -174,21 +94,22 @@ def handle_receive(event): dummy_connection = config[DOMAIN][ATTR_DUMMY] if dummy_connection: - hass.data[RFXOBJECT] =\ - rfxtrxmod.Connect(device, None, debug=debug, - transport_protocol=rfxtrxmod.DummyTransport2) + rfx_object = rfxtrxmod.Connect( + device, None, debug=debug, + transport_protocol=rfxtrxmod.DummyTransport2) else: - hass.data[RFXOBJECT] = rfxtrxmod.Connect(device, None, debug=debug) + rfx_object = rfxtrxmod.Connect(device, None, debug=debug) def _start_rfxtrx(event): - hass.data[RFXOBJECT].event_callback = handle_receive + rfx_object.event_callback = handle_receive hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_rfxtrx) def _shutdown_rfxtrx(event): """Close connection with RFXtrx.""" - hass.data[RFXOBJECT].close_connection() + rfx_object.close_connection() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) + hass.data[DATA_RFXOBJECT] = rfx_object return True @@ -215,12 +136,13 @@ def get_rfx_object(packetid): def get_pt2262_deviceid(device_id, nb_data_bits): """Extract and return the address bits from a Lighting4/PT2262 packet.""" + if nb_data_bits is None: + return import binascii try: data = bytearray.fromhex(device_id) except ValueError: return None - mask = 0xFF & ~((1 << nb_data_bits) - 1) data[len(data)-1] &= mask @@ -243,16 +165,14 @@ def get_pt2262_cmd(device_id, data_bits): # pylint: disable=unused-variable def get_pt2262_device(device_id): """Look for the device which id matches the given device_id parameter.""" - for dev_id, device in RFX_DEVICES.items(): - try: - if device.masked_id == get_pt2262_deviceid(device_id, - device.data_bits): - _LOGGER.info("rfxtrx: found matching device %s for %s", - device_id, - get_pt2262_deviceid(device_id, device.data_bits)) - return device - except AttributeError: - continue + for device in RFX_DEVICES.values(): + if (hasattr(device, 'is_lighting4') and + device.masked_id == get_pt2262_deviceid(device_id, + device.data_bits)): + _LOGGER.debug("rfxtrx: found matching device %s for %s", + device_id, + device.masked_id) + return device return None @@ -260,10 +180,10 @@ def get_pt2262_device(device_id): def find_possible_pt2262_device(device_id): """Look for the device which id matches the given device_id parameter.""" for dev_id, device in RFX_DEVICES.items(): - if len(dev_id) == len(device_id): + if hasattr(device, 'is_lighting4') and len(dev_id) == len(device_id): size = None - for i in range(0, len(dev_id)): - if dev_id[i] != device_id[i]: + for i, (char1, char2) in enumerate(zip(dev_id, device_id)): + if char1 != char2: break size = i @@ -296,11 +216,11 @@ def get_devices_from_config(config, device): device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: continue - _LOGGER.info("Add %s rfxtrx", entity_info[ATTR_NAME]) + _LOGGER.debug("Add %s rfxtrx", entity_info[ATTR_NAME]) # Check if i must fire event - fire_event = entity_info[ATTR_FIREEVENT] - datas = {ATTR_STATE: False, ATTR_FIREEVENT: fire_event} + fire_event = entity_info[ATTR_FIRE_EVENT] + datas = {ATTR_STATE: False, ATTR_FIRE_EVENT: fire_event} new_device = device(entity_info[ATTR_NAME], event, datas, signal_repetitions) @@ -319,14 +239,14 @@ def get_new_device(event, config, device): return pkt_id = "".join("{0:02x}".format(x) for x in event.data) - _LOGGER.info( + _LOGGER.debug( "Automatic add %s rfxtrx device (Class: %s Sub: %s Packet_id: %s)", device_id, event.device.__class__.__name__, event.device.subtype, pkt_id ) - datas = {ATTR_STATE: False, ATTR_FIREEVENT: False} + datas = {ATTR_STATE: False, ATTR_FIRE_EVENT: False} signal_repetitions = config[CONF_SIGNAL_REPETITIONS] new_device = device(pkt_id, event, datas, signal_repetitions) @@ -371,7 +291,7 @@ def apply_received_command(event): ATTR_STATE: event.values['Command'].lower() } ) - _LOGGER.info( + _LOGGER.debug( "Rfxtrx fired event: (event_type: %s, %s: %s, %s: %s)", EVENT_BUTTON_PRESSED, ATTR_ENTITY_ID, @@ -393,8 +313,14 @@ def __init__(self, name, event, datas, signal_repetitions): self._name = name self._event = event self._state = datas[ATTR_STATE] - self._should_fire_event = datas[ATTR_FIREEVENT] + self._should_fire_event = datas[ATTR_FIRE_EVENT] self._brightness = 0 + self.added_to_hass = False + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe RFXtrx events.""" + self.added_to_hass = True @property def should_poll(self): @@ -429,44 +355,41 @@ def update_state(self, state, brightness=0): """Update det state of the device.""" self._state = state self._brightness = brightness - self.schedule_update_ha_state() + if self.added_to_hass: + self.schedule_update_ha_state() def _send_command(self, command, brightness=0): if not self._event: return + rfx_object = self.hass.data[DATA_RFXOBJECT] if command == "turn_on": for _ in range(self.signal_repetitions): - self._event.device.send_on(self.hass.data[RFXOBJECT] - .transport) + self._event.device.send_on(rfx_object.transport) self._state = True elif command == "dim": for _ in range(self.signal_repetitions): - self._event.device.send_dim(self.hass.data[RFXOBJECT] - .transport, brightness) + self._event.device.send_dim(rfx_object.transport, brightness) self._state = True elif command == 'turn_off': for _ in range(self.signal_repetitions): - self._event.device.send_off(self.hass.data[RFXOBJECT] - .transport) + self._event.device.send_off(rfx_object.transport) self._state = False self._brightness = 0 elif command == "roll_up": for _ in range(self.signal_repetitions): - self._event.device.send_open(self.hass.data[RFXOBJECT] - .transport) + self._event.device.send_open(rfx_object.transport) elif command == "roll_down": for _ in range(self.signal_repetitions): - self._event.device.send_close(self.hass.data[RFXOBJECT] - .transport) + self._event.device.send_close(rfx_object.transport) elif command == "stop_roll": for _ in range(self.signal_repetitions): - self._event.device.send_stop(self.hass.data[RFXOBJECT] - .transport) + self._event.device.send_stop(rfx_object.transport) - self.schedule_update_ha_state() + if self.added_to_hass: + self.schedule_update_ha_state() diff --git a/homeassistant/components/ring.py b/homeassistant/components/ring.py index a1529fddbd60c..1a15e22fca08c 100644 --- a/homeassistant/components/ring.py +++ b/homeassistant/components/ring.py @@ -5,22 +5,23 @@ https://home-assistant.io/components/ring/ """ import logging +from requests.exceptions import HTTPError, ConnectTimeout + import voluptuous as vol -import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -from requests.exceptions import HTTPError, ConnectTimeout - -REQUIREMENTS = ['ring_doorbell==0.1.4'] +REQUIREMENTS = ['ring_doorbell==0.1.8'] _LOGGER = logging.getLogger(__name__) CONF_ATTRIBUTION = "Data provided by Ring.com" NOTIFICATION_ID = 'ring_notification' -NOTIFICATION_TITLE = 'Ring Sensor Setup' +NOTIFICATION_TITLE = 'Ring Setup' +DATA_RING = 'ring' DOMAIN = 'ring' DEFAULT_CACHEDB = '.ring_cache.pickle' DEFAULT_ENTITY_NAMESPACE = 'ring' @@ -36,8 +37,8 @@ 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 0000000000000..a7b33b4c69702 --- /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/satel_integra.py b/homeassistant/components/satel_integra.py new file mode 100644 index 0000000000000..4b61ff15c0857 --- /dev/null +++ b/homeassistant/components/satel_integra.py @@ -0,0 +1,152 @@ +""" +Support for Satel Integra devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/satel_integra/ +""" +# pylint: disable=invalid-name + +import asyncio +import logging + + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send + +REQUIREMENTS = ['satel_integra==0.1.0'] + +DEFAULT_ALARM_NAME = 'satel_integra' +DEFAULT_PORT = 7094 +DEFAULT_CONF_ARM_HOME_MODE = 1 +DEFAULT_DEVICE_PARTITION = 1 +DEFAULT_ZONE_TYPE = 'motion' + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'satel_integra' + +DATA_SATEL = 'satel_integra' + +CONF_DEVICE_HOST = 'host' +CONF_DEVICE_PORT = 'port' +CONF_DEVICE_PARTITION = 'partition' +CONF_ARM_HOME_MODE = 'arm_home_mode' +CONF_ZONE_NAME = 'name' +CONF_ZONE_TYPE = 'type' +CONF_ZONES = 'zones' + +ZONES = 'zones' + +SIGNAL_PANEL_MESSAGE = 'satel_integra.panel_message' +SIGNAL_PANEL_ARM_AWAY = 'satel_integra.panel_arm_away' +SIGNAL_PANEL_ARM_HOME = 'satel_integra.panel_arm_home' +SIGNAL_PANEL_DISARM = 'satel_integra.panel_disarm' + +SIGNAL_ZONES_UPDATED = 'satel_integra.zones_updated' + +ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_ZONE_NAME): cv.string, + vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): cv.string}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE_HOST): cv.string, + vol.Optional(CONF_DEVICE_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_DEVICE_PARTITION, + default=DEFAULT_DEVICE_PARTITION): cv.positive_int, + vol.Optional(CONF_ARM_HOME_MODE, + default=DEFAULT_CONF_ARM_HOME_MODE): vol.In([1, 2, 3]), + vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA}, + }), +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the Satel Integra component.""" + conf = config.get(DOMAIN) + + zones = conf.get(CONF_ZONES) + host = conf.get(CONF_DEVICE_HOST) + port = conf.get(CONF_DEVICE_PORT) + partition = conf.get(CONF_DEVICE_PARTITION) + + from satel_integra.satel_integra import AsyncSatel, AlarmState + + controller = AsyncSatel(host, port, zones, hass.loop, partition) + + hass.data[DATA_SATEL] = controller + + result = yield from controller.connect() + + if not result: + return False + + @asyncio.coroutine + def _close(): + controller.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close()) + + _LOGGER.debug("Arm home config: %s, mode: %s ", + conf, + conf.get(CONF_ARM_HOME_MODE)) + + task_control_panel = hass.async_add_job( + async_load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config)) + + task_zones = hass.async_add_job( + async_load_platform(hass, 'binary_sensor', DOMAIN, + {CONF_ZONES: zones}, config)) + + yield from asyncio.wait([task_control_panel, task_zones], loop=hass.loop) + + @callback + def alarm_status_update_callback(status): + """Send status update received from alarm to home assistant.""" + _LOGGER.debug("Alarm status callback, status: %s", status) + hass_alarm_status = STATE_ALARM_DISARMED + + if status == AlarmState.ARMED_MODE0: + hass_alarm_status = STATE_ALARM_ARMED_AWAY + + elif status in [ + AlarmState.ARMED_MODE0, + AlarmState.ARMED_MODE1, + AlarmState.ARMED_MODE2, + AlarmState.ARMED_MODE3 + ]: + hass_alarm_status = STATE_ALARM_ARMED_HOME + + elif status in [AlarmState.TRIGGERED, AlarmState.TRIGGERED_FIRE]: + hass_alarm_status = STATE_ALARM_TRIGGERED + + elif status == AlarmState.DISARMED: + hass_alarm_status = STATE_ALARM_DISARMED + + _LOGGER.debug("Sending hass_alarm_status: %s...", hass_alarm_status) + async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE, hass_alarm_status) + + @callback + def zones_update_callback(status): + """Update zone objects as per notification from the alarm.""" + _LOGGER.debug("Zones callback , status: %s", status) + async_dispatcher_send(hass, SIGNAL_ZONES_UPDATED, status[ZONES]) + + # Create a task instead of adding a tracking job, since this task will + # run until the connection to satel_integra is closed. + hass.loop.create_task(controller.keep_alive()) + hass.loop.create_task( + controller.monitor_status( + alarm_status_update_callback, + zones_update_callback) + ) + + return True diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index fbfe2b6959a8c..7b76836555c73 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -5,6 +5,7 @@ https://home-assistant.io/components/scene/ """ import asyncio +import importlib import logging import voluptuous as vol @@ -16,7 +17,6 @@ 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) @@ -68,22 +72,20 @@ def activate(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_TURN_ON, data) -@asyncio.coroutine -def async_setup(hass, config): +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) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_handle_scene_service(service): + async def async_handle_scene_service(service): """Handle calls to the switch services.""" target_scenes = component.async_extract_from_service(service) tasks = [scene.async_activate() for scene in target_scenes] if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_handle_scene_service, @@ -92,6 +94,16 @@ def async_handle_scene_service(service): 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 new file mode 100644 index 0000000000000..3eb73736717ef --- /dev/null +++ b/homeassistant/components/scene/deconz.py @@ -0,0 +1,48 @@ +""" +Support for deCONZ scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.deconz/ +""" +from homeassistant.components.deconz import ( + DOMAIN as DATA_DECONZ, DATA_DECONZ_ID) +from homeassistant.components.scene import Scene + +DEPENDENCIES = ['deconz'] + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """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 = [] + + for scene in scenes.values(): + entities.append(DeconzScene(scene)) + async_add_devices(entities) + + +class DeconzScene(Scene): + """Representation of a deCONZ scene.""" + + def __init__(self, scene): + """Set up a scene.""" + self._scene = scene + + async def async_added_to_hass(self): + """Subscribe to sensors events.""" + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._scene.deconz_id + + async def async_activate(self): + """Activate the scene.""" + await self._scene.async_set_state({}) + + @property + def name(self): + """Return the name of the scene.""" + return self._scene.full_name diff --git a/homeassistant/components/scene/hunterdouglas_powerview.py b/homeassistant/components/scene/hunterdouglas_powerview.py index 0f5ba85c342d7..4f5ac5725a3d8 100644 --- a/homeassistant/components/scene/hunterdouglas_powerview.py +++ b/homeassistant/components/scene/hunterdouglas_powerview.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import async_generate_entity_id _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiopvapi==1.4'] +REQUIREMENTS = ['aiopvapi==1.5.4'] ENTITY_ID_FORMAT = DOMAIN + '.{}' HUB_ADDRESS = 'address' @@ -39,46 +39,53 @@ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up home assistant scene entries.""" - from aiopvapi.hub import Hub + # from aiopvapi.hub import Hub + from aiopvapi.scenes import Scenes + from aiopvapi.rooms import Rooms + from aiopvapi.resources.scene import Scene as PvScene hub_address = config.get(HUB_ADDRESS) websession = async_get_clientsession(hass) - _hub = Hub(hub_address, hass.loop, websession) - _scenes = yield from _hub.scenes.get_scenes() - _rooms = yield from _hub.rooms.get_rooms() + _scenes = yield from Scenes( + hub_address, hass.loop, websession).get_resources() + _rooms = yield from Rooms( + hub_address, hass.loop, websession).get_resources() if not _scenes or not _rooms: + _LOGGER.error( + "Unable to initialize PowerView hub: %s", hub_address) return - pvscenes = (PowerViewScene(hass, _scene, _rooms, _hub) - for _scene in _scenes[SCENE_DATA]) + pvscenes = (PowerViewScene(hass, + PvScene(_raw_scene, hub_address, hass.loop, + websession), _rooms) + for _raw_scene in _scenes[SCENE_DATA]) async_add_devices(pvscenes) class PowerViewScene(Scene): """Representation of a Powerview scene.""" - def __init__(self, hass, scene_data, room_data, hub): + def __init__(self, hass, scene, room_data): """Initialize the scene.""" - self.hub = hub + self._scene = scene self.hass = hass - self._sync_room_data(room_data, scene_data) - self._name = scene_data[SCENE_NAME] - self._scene_id = scene_data[SCENE_ID] + self._room_name = None + self._sync_room_data(room_data) self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, str(scene_data[SCENE_ID]), hass=hass) + ENTITY_ID_FORMAT, str(self._scene.id), hass=hass) - def _sync_room_data(self, room_data, scene_data): - """Sync the room data.""" + def _sync_room_data(self, room_data): + """Sync room data.""" room = next((room for room in room_data[ROOM_DATA] - if room[ROOM_ID] == scene_data[ROOM_ID_IN_SCENE]), {}) + if room[ROOM_ID] == self._scene.room_id), {}) self._room_name = room.get(ROOM_NAME, '') @property def name(self): """Return the name of the scene.""" - return self._name + return self._scene.name @property def device_state_attributes(self): @@ -92,4 +99,4 @@ def icon(self): def async_activate(self): """Activate scene. Try to get entities into requested state.""" - yield from self.hub.scenes.activate_scene(self._scene_id) + yield from self._scene.activate() diff --git a/homeassistant/components/scene/knx.py b/homeassistant/components/scene/knx.py new file mode 100644 index 0000000000000..901e25aea8201 --- /dev/null +++ b/homeassistant/components/scene/knx.py @@ -0,0 +1,75 @@ +""" +Support for KNX scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.knx/ +""" +import voluptuous as vol + +from homeassistant.components.knx import ATTR_DISCOVER_DEVICES, DATA_KNX +from homeassistant.components.scene import CONF_PLATFORM, Scene +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +CONF_SCENE_NUMBER = 'scene_number' + +DEFAULT_NAME = 'KNX SCENE' +DEPENDENCIES = ['knx'] + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'knx', + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ADDRESS): cv.string, + vol.Required(CONF_SCENE_NUMBER): cv.positive_int, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the scenes for KNX platform.""" + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, async_add_devices) + else: + async_add_devices_config(hass, config, async_add_devices) + + +@callback +def async_add_devices_discovery(hass, discovery_info, async_add_devices): + """Set up scenes for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXScene(device)) + async_add_devices(entities) + + +@callback +def async_add_devices_config(hass, config, async_add_devices): + """Set up scene for KNX platform configured within platform.""" + import xknx + scene = xknx.devices.Scene( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + scene_number=config.get(CONF_SCENE_NUMBER)) + hass.data[DATA_KNX].xknx.devices.add(scene) + async_add_devices([KNXScene(scene)]) + + +class KNXScene(Scene): + """Representation of a KNX scene.""" + + def __init__(self, scene): + """Init KNX scene.""" + self.scene = scene + + @property + def name(self): + """Return the name of the scene.""" + return self.scene.name + + async def async_activate(self): + """Activate the scene.""" + await self.scene.run() diff --git a/homeassistant/components/scene/lifx_cloud.py b/homeassistant/components/scene/lifx_cloud.py index e6f5be71a80ea..ffbb10cba4eca 100644 --- a/homeassistant/components/scene/lifx_cloud.py +++ b/homeassistant/components/scene/lifx_cloud.py @@ -7,15 +7,15 @@ import asyncio import logging -import voluptuous as vol - import aiohttp +from aiohttp.hdrs import AUTHORIZATION import async_timeout +import voluptuous as vol from homeassistant.components.scene import Scene -from homeassistant.const import (CONF_PLATFORM, CONF_TOKEN, CONF_TIMEOUT) +from homeassistant.const import CONF_TOKEN, CONF_TIMEOUT, CONF_PLATFORM +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.aiohttp_client import (async_get_clientsession) _LOGGER = logging.getLogger(__name__) @@ -37,7 +37,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): timeout = config.get(CONF_TIMEOUT) headers = { - "Authorization": "Bearer %s" % token, + AUTHORIZATION: "Bearer {}".format(token), } url = LIFX_API_URL.format('scenes') diff --git a/homeassistant/components/scene/litejet.py b/homeassistant/components/scene/litejet.py index 432ce060774bf..37fb58d8dc7ae 100644 --- a/homeassistant/components/scene/litejet.py +++ b/homeassistant/components/scene/litejet.py @@ -42,11 +42,6 @@ def name(self): """Return the name of the scene.""" return self._name - @property - def should_poll(self): - """Return that polling is not necessary.""" - return False - @property def device_state_attributes(self): """Return the device-specific state attributes.""" @@ -54,6 +49,6 @@ def device_state_attributes(self): ATTR_NUMBER: self._index } - def activate(self, **kwargs): + def activate(self): """Activate the scene.""" self._lj.activate_scene(self._index) diff --git a/homeassistant/components/scene/lutron_caseta.py b/homeassistant/components/scene/lutron_caseta.py index b98f7f3e6ea46..0d9024d194e59 100644 --- a/homeassistant/components/scene/lutron_caseta.py +++ b/homeassistant/components/scene/lutron_caseta.py @@ -4,17 +4,19 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/scene.lutron_caseta/ """ +import asyncio import logging -from homeassistant.components.scene import Scene from homeassistant.components.lutron_caseta import LUTRON_CASETA_SMARTBRIDGE +from homeassistant.components.scene import Scene _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['lutron_caseta'] -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Lutron Caseta lights.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -23,7 +25,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = LutronCasetaScene(scenes[scene], bridge) devs.append(dev) - add_devices(devs, True) + async_add_devices(devs, True) class LutronCasetaScene(Scene): @@ -40,16 +42,7 @@ def name(self): """Return the name of the scene.""" return self._scene_name - @property - def should_poll(self): - """Return that polling is not necessary.""" - return False - - @property - def is_on(self): - """There is no way of detecting if a scene is active (yet).""" - return False - - def activate(self, **kwargs): + @asyncio.coroutine + def async_activate(self): """Activate the scene.""" self._bridge.activate_scene(self._scene_id) diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml new file mode 100644 index 0000000000000..ee255affe44ed --- /dev/null +++ b/homeassistant/components/scene/services.yaml @@ -0,0 +1,8 @@ +# Describes the format for available scene services + +turn_on: + description: Activate a scene. + fields: + entity_id: + description: Name(s) of scenes to turn on + example: 'scene.romantic' diff --git a/homeassistant/components/scene/tahoma.py b/homeassistant/components/scene/tahoma.py new file mode 100644 index 0000000000000..3920662390104 --- /dev/null +++ b/homeassistant/components/scene/tahoma.py @@ -0,0 +1,48 @@ +""" +Support for Tahoma scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.tahoma/ +""" +import logging + +from homeassistant.components.scene import Scene +from homeassistant.components.tahoma import ( + DOMAIN as TAHOMA_DOMAIN) + +DEPENDENCIES = ['tahoma'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tahoma scenes.""" + controller = hass.data[TAHOMA_DOMAIN]['controller'] + scenes = [] + for scene in hass.data[TAHOMA_DOMAIN]['scenes']: + scenes.append(TahomaScene(scene, controller)) + add_devices(scenes, True) + + +class TahomaScene(Scene): + """Representation of a Tahoma scene entity.""" + + def __init__(self, tahoma_scene, controller): + """Initialize the scene.""" + self.tahoma_scene = tahoma_scene + self.controller = controller + self._name = self.tahoma_scene.name + + def activate(self): + """Activate the scene.""" + self.controller.launch_action_group(self.tahoma_scene.oid) + + @property + def name(self): + """Return the name of the scene.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the scene.""" + return {'tahoma_scene_oid': self.tahoma_scene.oid} diff --git a/homeassistant/components/scene/velux.py b/homeassistant/components/scene/velux.py index 9da7a6621173e..63bb23b1086cb 100644 --- a/homeassistant/components/scene/velux.py +++ b/homeassistant/components/scene/velux.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/scene.velux/ """ + from homeassistant.components.scene import Scene from homeassistant.components.velux import _LOGGER, DATA_VELUX @@ -11,26 +12,21 @@ DEPENDENCIES = ['velux'] -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 scenes for velux platform.""" - if DATA_VELUX not in hass.data \ - or not hass.data[DATA_VELUX].initialized: - return False - entities = [] for scene in hass.data[DATA_VELUX].pyvlx.scenes: - entities.append(VeluxScene(hass, scene)) - add_devices(entities) - return True + entities.append(VeluxScene(scene)) + async_add_devices(entities) class VeluxScene(Scene): """Representation of a velux scene.""" - def __init__(self, hass, scene): + def __init__(self, scene): """Init velux scene.""" _LOGGER.info("Adding VELUX scene: %s", scene) - self.hass = hass self.scene = scene @property @@ -38,16 +34,6 @@ def name(self): """Return the name of the scene.""" return self.scene.name - @property - def should_poll(self): - """Return that polling is not necessary.""" - return False - - @property - def is_on(self): - """There is no way of detecting if a scene is active (yet).""" - return False - - def activate(self, **kwargs): + async def async_activate(self): """Activate the scene.""" - self.hass.async_add_job(self.scene.run()) + await self.scene.run() diff --git a/homeassistant/components/scene/vera.py b/homeassistant/components/scene/vera.py new file mode 100644 index 0000000000000..4f580356fbb63 --- /dev/null +++ b/homeassistant/components/scene/vera.py @@ -0,0 +1,55 @@ +""" +Support for Vera scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.vera/ +""" +import logging + +from homeassistant.util import slugify +from homeassistant.components.scene import Scene +from homeassistant.components.vera import ( + VERA_CONTROLLER, VERA_SCENES, VERA_ID_FORMAT) + +DEPENDENCIES = ['vera'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Vera scenes.""" + add_devices( + [VeraScene(scene, hass.data[VERA_CONTROLLER]) + for scene in hass.data[VERA_SCENES]], True) + + +class VeraScene(Scene): + """Representation of a Vera scene entity.""" + + def __init__(self, vera_scene, controller): + """Initialize the scene.""" + self.vera_scene = vera_scene + self.controller = controller + + self._name = self.vera_scene.name + # Append device id to prevent name clashes in HA. + self.vera_id = VERA_ID_FORMAT.format( + slugify(vera_scene.name), vera_scene.scene_id) + + def update(self): + """Update the scene status.""" + self.vera_scene.refresh() + + def activate(self): + """Activate the scene.""" + self.vera_scene.activate() + + @property + def name(self): + """Return the name of the scene.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the scene.""" + return {'vera_scene_id': self.vera_scene.vera_scene_id} diff --git a/homeassistant/components/scene/wink.py b/homeassistant/components/scene/wink.py index 008edf6f13169..5bd053bdd39d2 100644 --- a/homeassistant/components/scene/wink.py +++ b/homeassistant/components/scene/wink.py @@ -8,11 +8,12 @@ import logging from homeassistant.components.scene import Scene -from homeassistant.components.wink import WinkDevice, DOMAIN +from homeassistant.components.wink import DOMAIN, WinkDevice -DEPENDENCIES = ['wink'] _LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['wink'] + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Wink platform.""" @@ -34,14 +35,9 @@ def __init__(self, wink, hass): @asyncio.coroutine def async_added_to_hass(self): - """Callback when entity is added to hass.""" + """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['scene'].append(self) - @property - def is_on(self): - """Python-wink will always return False.""" - return self.wink.state() - - def activate(self, **kwargs): + def activate(self): """Activate the scene.""" self.wink.activate() diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py index 7be8bd8175e4c..a45f8ba893060 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -156,7 +156,7 @@ def _async_process_config(hass, config, component): def service_handler(service): """Execute a service call to script.